从C++标准库中得到的EDOCX1×0算法(及其表兄妹EDOCX1 1)和EDCOX1〔2〕在大多数实现中是一个复杂的、混合的更基本的排序算法,如选择排序、插入排序、快速排序、归并排序或堆排序。
这里和姐妹站点(如https://codereview.stackexchange.com/)上有许多与这些经典排序算法实现的错误、复杂性和其他方面相关的问题。所提供的大多数实现都由原始循环、使用索引操作和具体类型组成,并且通常都是从正确性和效率方面进行分析的重要部分。
问:上述经典排序算法如何使用现代C++实现?
百万千克1没有原始循环,但是结合了来自的标准库的算法构建块。百万千克1百万千克1迭代器接口和模板的使用,而不是索引操作和具体类型百万千克1百万千克1C++ 14风格,包括完整的标准库,以及语法降噪器,如EDCOX1、4、模板别名、透明比较器和多态LAMBDAS。百万千克1
型
笔记:
百万千克1有关排序算法实现的更多参考,请参阅维基百科、Rosetta代码或http://www.sorting-algorithms.com。/百万千克1百万千克1根据Sean Parent的约定(幻灯片39),原始循环是一个for循环,比用一个运算符组合两个函数的时间长。因此,f(g(x));或f(x); g(x);或f(x) + g(x);不是原始循环,下面的selection_sort和insertion_sort中的循环也不是原始循环。百万千克1百万千克1我遵循Scott Meyers的术语来表示当前的C++ 1Y已经作为C++ 14,并将C++ 98和C++ 03都表示为C++ 98,所以不要因此而燃烧我。百万千克1百万千克1正如在@ Mehrdad的评论中所建议的,我提供了四个实现作为回答的最后一个实例:C++ 14、C++ 11、C++ 98和Boost和C++ 98。百万千克1百万千克1答案本身仅以C++ 14表示。在相关的地方,我表示不同语言版本之间的句法和库差异。百万千克1
型
- 将C++的FAQ标签添加到问题中是很好的,尽管它需要至少失去其中的一个。我建议删除版本(因为它是一个通用的C++问题,在大多数版本中都有可用的实现方式,需要进行一些修改)。
- @从技术上讲,圣殿骑士团如果不是常见问题,那么这个问题就太广泛了(猜测——我没有投反对票)。顺便说一下,干得好,有很多有用的信息,谢谢:)
算法构建块
我们首先从标准库组装算法构建块:好的。
1 2 3 4 5 6 7 8 9
| #include // 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 |
- 迭代器工具,如非成员EDCOX1,0,EDCOX1,1,以及EDCOX1,2,只有C++和11之外才可用。对于C++ 98,需要自己编写这些文件。在boost::begin()/boost::end()中有boost.range的替代品,在boost::next()中有boost.utility的替代品。
- EDCOX1的6度算法仅适用于C++ 11及其他。对于C++ 98,这可以用EDOCX1,7,和手写函数对象来实现。算法还提供了一个boost::algorithm::is_sorted作为替代。
- EDCOX1的9度算法仅适用于C++ 11及其他。
句法上的好东西
C++ 14为EDOCX1·10的形式提供了透明的比较器,它们对它们的参数起着多态作用。这就避免了必须提供迭代器的类型。这可以与C++ 11的默认函数模板参数组合使用,从而为排序算法创建一个单一的重载,这些算法使用EDCOX1×11作为比较和具有用户定义的比较函数对象的排序算法。好的。
1 2
| template<class It, class Compare = std::less<>>
void xxx_sort(It first, It last, Compare cmp = Compare{}); |
在C++ 11中,可以定义一个可重复使用的模板别名来提取迭代器的值类型,它对排序算法的签名添加了小杂波:好的。
1 2 3 4 5
| 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{}); |
在C++ 98中,需要编写两个重载并使用冗长的EDCOX1和12的语法。好的。
1 2 3 4 5 6 7 8
| 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>());
} |
- 另一种语法精确性是C++ 14通过多态LAMBDAS(用EDOCX1×13个参数(如函数模板参数推导)来封装用户定义的比较器。
- C++ 11只有单形LAMBDAS,需要使用上面的模板别名EDCOX1(14)。
- 在C++ 98中,一个要么需要编写一个独立的函数对象,要么求助于冗长的EDCOX1,15,EDCX1,16,EDCX1,17的语法类型。
- boost.bind使用boost::bind和_1/_2占位符语法改进了这一点。
- C++ 11和EXCEL也有EDOCX1,21,而C++ 98需要EDOCX1,22,EDCOX1,17,围绕函数对象。
C++风格
目前还没有一般可接受的C++ 14风格。不管是好是坏,我都密切关注Scott Meyers的草稿《现代C++》和《萨特》改编的《GotW》。我使用以下样式建议:好的。
- Herb Sutter的"几乎总是自动的"和Scott Meyers的"更喜欢自动的,而不是特定的类型声明"推荐,虽然其清晰度有时有争议,但其简洁性是无与伦比的。
- Scott Meyers的"在创建对象时区分()和{}",并始终选择有支撑的初始化{},而不是好的带圆括号的初始化(),以避免通用代码中最麻烦的解析问题。
- ScottMeyers的"比typedef更喜欢别名声明"。对于模板来说,这无论如何都是必须的,在任何地方使用它而不是使用typedef,可以节省时间并增加一致性。
- 我在某些地方使用for (auto it = first; it != last; ++it)模式,以便对已经排序的子范围进行循环不变检查。在生产代码中,在循环中的某个地方使用while (first != last)和++first可能会稍微好一些。
选择排序
选择排序不以任何方式适应数据,因此其运行时始终是O(N2)。但是,选择排序具有最小化交换数量的特性。在交换项目的成本很高的应用中,选择排序很可能是选择的算法。好的。
要使用标准库来实现它,请重复使用std::min_element查找剩余的最小元素,并使用iter_swap将其替换到位:好的。
1 2 3 4 5 6 7 8 9
| 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));
}
} |
注意,selection_sort已经处理过的范围[first, it)排序为循环不变量。与std::sort的随机访问迭代器相比,最小的需求是正向迭代器。好的。
省略细节:好的。
- 选择排序可以通过早期测试if (std::distance(first, last) <= 1) return;进行优化(或者对于正向/双向迭代器:if (first == last || std::next(first) == last) return;)。
- 对于双向迭代器,可以将上面的测试与间隔[first, std::prev(last))上的循环结合起来,因为最后一个元素保证是最小的剩余元素,并且不需要交换。
插入排序
虽然插入排序是O(N2)最坏情况下的基本排序算法之一,但无论是在数据接近排序时(因为它是自适应的),还是在问题大小较小时(因为它的开销较低),插入排序都是首选算法。由于这些原因,并且由于插入排序也很稳定,因此对于较高的开销划分和克服排序算法(如合并排序或快速排序),插入排序通常用作递归基本情况(当问题大小较小时)。好的。
使用标准库实现insertion_sort,重复使用std::upper_bound查找当前元素需要去的位置,使用std::rotate在输入范围内将其余元素向上移动:好的。
1 2 3 4 5 6 7 8 9
| 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));
}
} |
注意,insertion_sort已经处理过的范围[first, it)排序为循环不变量。插入排序也适用于正向迭代器。好的。
省略细节:好的。
- 插入排序可以通过早期测试if (std::distance(first, last) <= 1) return;(或者对于正向/双向迭代器:if (first == last || std::next(first) == last) return;)和在间隔[std::next(first), last)上的循环进行优化,因为第一个元素保证在适当的位置并且不需要旋转。
- 对于双向迭代器,可以使用标准库的std::find_if_not算法将查找插入点的二进制搜索替换为反向线性搜索。
下面的片段有四个生动的例子(C++ 14,C++ 11,C++ 98和Boost,C++ 98):好的。
1 2 3 4
| 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(); |
- 对于随机输入,这提供了O(N2)比较,但对于几乎排序的输入,这改进了O(N)比较。二进制搜索总是使用O(N log N)比较。
- 对于较小的输入范围,线性搜索的更好的内存位置(缓存、预取)也可能主导二进制搜索(当然,应该对此进行测试)。
快速排序
仔细实施后,快速排序是可靠的,并且具有预期的O(N log N)复杂性,但是使用O(N2)最坏情况下的复杂性,可以通过敌方选择的输入数据触发。当不需要稳定的排序时,快速排序是一种很好的通用排序。好的。
即使对于最简单的版本,使用标准库实现快速排序也比使用其他经典排序算法要复杂得多。下面的方法使用一些迭代器实用程序来定位输入范围[first, last)的中间元素作为轴心,然后使用两个对std::partition的调用(即O(N))来将输入范围分别划分为小于、等于和大于所选轴心的元素段。最后,对元素小于和大于轴的两个外部段进行递归排序:好的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| 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));
} |
然而,要获得正确和高效,快速排序相当困难,因为必须仔细检查上述每个步骤,并针对生产级代码进行优化。特别是,对于O(N log N)复杂性,数据透视必须导致输入数据的平衡分区,这通常不能保证O(1)数据透视,但如果将数据透视设置为输入范围的O(N)中位数,则可以保证。好的。
省略细节:好的。
- 上述实现尤其容易受到特殊输入的影响,例如,对于"器官管"输入的1, 2, 3, ..., N/2, ... 3, 2, 1,它的O(N^2)复杂性(因为中间部分总是大于所有其他元素)。
- 从输入范围中随机选择的元素中选择三取三的中位数,以防止几乎排序的输入,否则复杂性将恶化为O(N^2)。
- 对std::partition的两个调用所示的三向分区(分离小于、等于和大于pivot的元素)并不是实现此结果的最有效的O(N)算法。
- 对于随机访问迭代器,可以通过使用std::nth_element(first, middle, last)选择中间数据透视,然后递归调用quick_sort(first, middle, cmp)和quick_sort(middle, last, cmp)来实现保证的O(N log N)复杂性。
- 然而,这项保证是有代价的,因为std::nth_element的O(N)复杂性的常数因子可能比O(1)复杂性的常数因子更昂贵,后者是一个中位数为3的支点,随后是一个O(N)调用std::partition的过程(这是一个对缓存友好的单转发数据)。
归并排序
如果不考虑使用O(N)额外空间,那么合并排序是一个很好的选择:它是唯一稳定的O(N log N)排序算法。好的。
使用标准算法很容易实现:使用一些迭代器实用程序定位输入范围[first, last)的中间位置,并将两个递归排序的段与std::inplace_merge组合:好的。
1 2 3 4 5 6 7 8 9 10
| 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));
} |
合并排序需要双向迭代器,瓶颈是std::inplace_merge。注意,在对链表排序时,合并排序只需要O(log N)额外的空间(用于递归)。后一种算法由标准库中的std::list::sort实现。好的。堆排序
堆排序很容易实现,执行O(N log N)就地排序,但不稳定。好的。
第一个循环,O(N)的"heapify"阶段,将数组放入堆顺序。第二个循环是O(N log N"sortdown"阶段,重复提取最大值并恢复堆顺序。标准库非常简单:好的。
1 2 3 4 5 6
| 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));
} |
如果您认为使用std::make_heap和std::sort_heap是"欺骗",您可以更深入一个层次,分别根据std::push_heap和std::pop_heap编写这些函数:好的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| 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 |
标准库将push_heap和pop_heap都指定为复杂性O(log N)。但是请注意,在[first, last)范围内的外循环导致make_heap的O(N log N)复杂性,而std::make_heap只有O(N)复杂性。对于整个O(N log N)的复杂性,heap_sort并不重要。好的。
省略:O(N)实施make_heap号好的。测试
这里有四个活生生的例子(C++ 14,C++ 11,C++ 98和Boost,C++ 98)在各种输入上测试所有五个算法(不意味着是穷尽的或严格的)。注意到LOC的巨大差异:C++ 11/C++ 14需要大约130 LOC、C++ 98和Boost 190(+50%)和C++ 98大于270(+100%)。好的。好啊。
- 我有点不同意auto it = first模式。有些迭代器不具有一般的可复制性,我怀疑您是否可以依赖编译器来优化副本,只要在可能的情况下使用first迭代器,它就因为这个原因被按值传递。
- 虽然我不同意你使用auto(许多人不同意我的观点),但我很高兴看到标准库算法被很好地使用。在看到肖恩父母的谈话之后,我一直想看看这种代码的一些例子。另外,我不知道std::iter_swap的存在,尽管它在中似乎很奇怪。
- @sbabbi tnx,这是一个很好的观点,让我更新一下。注意,在selection_sort和insertion_sort中,it用于对循环不变量执行assert()。在生产代码中,循环中的while(first != last)和++first可能更好。
- @约瑟夫曼斯菲尔德是的,我是"auto"的忠实粉丝,一直到银行":-)我会发表评论。顺便说一句,iter_swap(a, b)只是做swap(*a, *b)而已,但我用前者来区别std::rotate更偏执。
- 除非递归太深,否则HP/Microsoft Std::Sort()使用快速排序,在这种情况下,它将切换到堆排序。HP/Microsoft Std::Stable_Sort()使用合并排序。显然,由于递归代码的残留(现在只出现一个级别),它分配了一半数据大小的临时缓冲区,并对每一半进行非递归自下而上的合并排序,然后将一半移动到临时缓冲区中并进行最终合并。这是旧代码,因为版权日期是1994年。
- @rcgldr当前的libstdc++/libc++实现也是一个复杂的例程组合,它适应输入数据的大小和性质。
- @templaterex iirc iter_swap专门用于列表迭代器,只是调整一些指针,而不是移动值。
- @sbabbi整个标准库基于迭代器复制成本低的原则;例如,它通过值传递它们。如果复制迭代器不便宜,那么到处都会遇到性能问题。
- 伟大的职位。关于[std::]make_heap的作弊部分。如果std::make_heap被认为是作弊,那么std::push_heap也会被认为是作弊。即作弊=未实现为堆结构定义的实际行为。我会发现它有指导意义,也包括推堆。
- @templaterex-对于HP/Microsoft STL实现,我没有提到的唯一适应是,如果元素的数量小于32,则使用插入排序。否则,正如我在上一篇文章中描述的那样,std::sort可以从快速排序切换到堆排序,std::stable_sort总是使用自下而上的合并排序(只要有32个或更多的元素)。
- @CaptainGiraffe在研究了push_heap和pop_heap的libc++/libstdc++实现之后,我觉得它们是原始算法,不容易表达成更小的标准算法。
- @ TePraceReX:似乎没有人提到过它,但是一个有深度限制的快速排序,然后切换到堆排序(通常切换为小集的插入排序),称为OnTrime:En.WijiTo.Org/WiKi/OnSt排序,这是我所知道的每一个库中使用的C++。
- @moingduck正确,并在问题的第一个链接中讨论。
- @templaterex允许我尝试不使用原始循环。我会把它作为答案添加,你可以随意编辑它。
- 值得注意的是:对于已经排序的数据,插入排序的版本是线性的,而不是线性的。
- 考虑到在大多数答案中,C++中没有具体的C++ 11,你最好在大多数地方停止使用EDCOX1,2,这样,人们也可以用C++来使用你的代码。
- @Mehrard I expanded the Q&;A with 4 verions:C++14/C++11/C++98 and Boost/C++98.主要问题仍然是C++14中有关于网络、图书馆和style问题的一些说明。You were right that most(except hep sort)of the answer carries through to C++98,albit at a+100%loc penalty.C++11/14真的是一辆车,不是一个快车!{ 1cHC2CECF}But thanks again for driving these points home!
- @Templatex under which license is this code?CC by-SA 3.0 with attribution?
- @Templatex could you provide code for the optimized versions(without asserts and with the proposed最优化)?In particular note that for the early test bestributions using EDOCX1 simplific 0 arnible is not efficient since for forward and biditorional iterators computeng the whole distance is o(n)and not required(the only thing required is to know if the distance is==-1).
- @gnzlbg the asserts you can comment out,of course.The early test can be tag-dispatched per iterator category,with the current version for random access,and EDOCX1 communal.I might update that late.Implementing the studff in the"omitted details"sections is beyond the scope of the question,IMO,because they contain links to entire Q&;as themselves.执行真正的工作的道路是困难的!
- 伟大的邮报Though,you've cheated with your quicksort by using EDOCX1 plus 2 in my opinion.EDOCX1 2 original does half a quicksort already(including the partitioning step and a returnion on the half that includes the n-th element you're interested in).
- @Sellibitze TNX,although EDOCX1,2 commercial 2.does not sort within the two half segments,it only sorts between the elements smaller/greater than the middle.In that sense,it is similar to EDOCX1 English 5 which separates on a predicate.Both are EDOCX1 penal 6.
- 和哪里是非比算法像计数或bucket sort?
- @Enedil I decided to limit the scope of my answer to comparison based sorting,mainly because the ones you mention are a bit more advanced.Feel free to add your own answer in modern C++style for these other interesting算法.
- For the EDOCX1 original 7,you mention you can iterate to EDOCX1 single 8 biditorional iterators,but in truth it can be done trivially with forward iterators as well:EDOCX1 penogical 9.
- @Mooingduck true,but the EDOCX1 theographic 10 communal would add an extra income per iteration.对于Bidir,你可以因素EDOCX1的英文字母8
- 伟大的邮报!不!喂
- 对我来说,你的悲伤是唯一需要勇气的一件事,这样才能成为一个有活力的经济体。That requirement results from EDOCX1 pental 12 using of a temporary buffer;EOP describes a way to merge W/o constructing any new elements(Subdivide each range using the same pivot,旋转the two middle ranges,return on each original range).
- @DYP not Sure I follow:AFICS STD::SORT requires its iterators to be valueswappable,I.E.dereferenced iterators are swapable,and swapable implies econ structive.STD类似::在插入中使用的旋转。
- While EDOCX1 13 silian requires econstruction,I don't see how/why swapable should require econstruction.Anyway,it's a good point:if a type is swapable it is probably also movable economy.
- @DYP I think the Chain of Reasoning Goes Like:Swappable requires in[Swappable.requirements]/2 calls to EDOCX1 original 13 to be valid,and EDOCX1 disciplinary 13 sil requirements in[utility.swap]/2 its arguments to be moved structive.Perhaps it would be good if the standard contained a note that swapable implies econ-structure and movable.
- I don't think EDOCX1 theocx1 13-needs to be valid.As far as I understand[Swappable.requirements],EDOCX1 universifical 17 needs to be valid.
- @DYP你说得对,N3048删除了可交换的可移动构造请求,主要是为了处理代理。然而,我在这个问答中使用的大多数突变序列算法,如rotate、inplace_merge和make_heap(实际上,所有std::sort、nth_element和相关算法)都要求迭代器值类型是可移动构造的。因此,即使基于旋转的合并排序仍然需要可移动构造,请参见[alg.rotate]/4。
- 哦,谢谢。我没有意识到这个被移除了。不过,我有点惊讶于rotate需要可移动构造。
- 嗯,用于快速分拣的nth_element确保了分拣本身的完美旋转。因此,通过将所有复杂的问题推到nth_element的实现中,您基本上可以避开这些复杂的问题。
- 但是,您不是基于中间的元素进行划分;您是根据实际中间值进行划分,这使得对"器官管道"输入的讨论产生误导(除非输入恰好是nth_element使用的算法的最坏情况,我对此表示怀疑)。
- 但是您的代码并没有使用"中间元素"(*pivot在nth_element调用之前)作为轴心——这将使用类似std::partition的东西;它使用的是序列的实际中间值。
- @T.C.更新了快速排序部分以反映您的评论。谢谢!
- 注意到QuickSort由于透视复制而无法对此处实现的只移动类型进行排序是很酷的。由于std::sort适用于只移动的类型,因此可能值得添加注释。
- @莫文谢谢你指出这一点。对于可移动构造类型,我必须执行如下操作:将pivot元素交换到末尾,执行第一个分区,将pivot交换回第二个分区和递归之前的中间位置。特别是对于前向迭代器,查找要进行交换的位置是令人讨厌的。
- @Morwenn我会记住这一点,直到有足够多的评论需要进行另一次编辑(我不想做太多的更新,以免看起来像是为了代表的目的而试图登上活动问题列表的顶端)。
- "对于随机访问迭代器,可以通过使用std::nth_元素(first,middle,last)选择中间数据透视来实现保证的O(n logn)复杂性"不是真的:std::nth_element在平均情况下仅为O(n),但标准对最坏情况没有限制。
- 我刚检查过:std::nth_element在libstdc++中是o(n log n)最坏的情况,在Microsoft实现中是o(n^2)。
- 因此,即使我们忽略了标准的要求并将重点放在实际的实现上,调用std::nth_element的QuickSort也不能保证O(n logn)。
- @Fanael你说得对,根据标准,nth_element平均只有O(N)。pre-c++11,std::sort平均只能保证是O(N log N)。看来,内向选择不仅使std::sort的担保O(N log N)成为可能,而且还可以用于担保O(N)nth_element的担保。它可能出现在未来的标准中,否则供应商竞争可能会使这成为实现质量问题。
- 第二个std::partition()在quick_sort()似乎完全毫无意义。另外,插入排序的思想不是首先确定插入元素应该结束的位置,然后移动数据:其思想是在移动数据的同时确定这个位置,避免不必要的数据加载到CPU中。
- 合并排序不是唯一稳定的O(n log n)排序。此外,至少有两种就地稳定O(n logn)排序的实现:grail排序和wikisort
- merge-sort的这种实现产生了不幸的效果,即对std::inplace_merge的每个调用都将分配和释放其临时缓冲区,而不是在调用之间重用该缓冲区。
- @戴维斯顿,你当然是对的。这个问题并不是写现实生活中优化排序例程的明确指南,而是展示如何组合基本的构建块。请参见CPP排序库,了解在现实生活中有多少额外的细节需要仔细关注:)
- 请注意,使用std::less<>会导致使用不同数组的指针时表现不佳。为了解决这个问题,您可能仍然需要使用std::less::value_type>。看到这个问题
- 有无数稳定的O(N log N)排序算法,而不仅仅是合并排序。事实上,合并排序在整个输入范围内都没有那么快——自然合并排序在排序的输出上是O(N),在同样的情况下使用O(1)额外的空间。对于给定的输入模式,很容易生成一个稳定的排序算法,该算法比O(N log N)运行得更快(不过,平均而言,O(N log N))。
型
另一个小的,相当优雅的,最初在代码评审中发现的。我认为这值得分享。
计数排序
虽然计数排序相当专业,但它是一种简单的整数排序算法,如果要排序的整数的值相距不太远,则计数排序通常会非常快。例如,如果需要对100万个已知介于0和100之间的整数集合进行排序,这可能是理想的。
要实现一个非常简单的计数排序,它同时处理有符号和无符号整数,需要在集合中找到要排序的最小和最大元素;它们的差异将告诉要分配的计数数组的大小。然后,对集合进行第二次传递,以计算每个元素的出现次数。最后,我们将每个整数的所需数量写回原始集合。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| 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++);
}
} |
虽然只有当要排序的整数的范围很小(通常不大于要排序的集合的大小)时才有用,但是使计数排序更通用会使其在最佳情况下更慢。如果范围不小,可以使用另一种算法,如基数排序、ska_排序或spreadsort。
省略细节:
百万千克1
我们可以通过算法接受的值范围的边界作为参数,以完全消除通过集合的第一个std::minmax_element。这将使算法更快,当一个有用的小范围限制是已知的其他方法。(不一定要精确;传递一个常数0到100仍然比额外传递一百万个元素要好得多,以发现真正的界限是1到95。即使是0到1000也值得;额外的元素只写一次零,读一次)。
百万千克1百万千克1
在飞行中生长counts是避免单独第一次通过的另一种方法。每次必须增长时将EDOCX1[1]大小加倍,每个排序元素的摊销时间为0(1)(有关指数增长是关键的证据,请参阅哈希表插入成本分析)。在最后为新的max增长是很容易与std::vector::resize添加新的零元素。在增加矢量后,可以使用std::copy_backward在飞行中更改min并在前面插入新的零元素。然后std::fill将新元素归零。
百万千克1百万千克1
counts增量循环是一个柱状图。如果数据很可能是高度重复的,并且存储箱的数量很小,那么有必要展开多个数组,以减少存储/重新加载到同一个存储箱的序列化数据依赖性瓶颈。这意味着在开始时计数为零的次数越多,在结束时循环的次数越多,但对于大多数CPU来说,这是值得的,例如数百万个0到100个数字,特别是如果输入可能已经(部分)排序并且长时间运行相同的数字。
百万千克1百万千克1
在上面的算法中,当每个元素都具有相同的值(在这种情况下,集合被排序)时,我们使用min == max检查提前返回。实际上,在查找集合的极值时,可以完全检查集合是否已排序,而不会浪费额外的时间(如果第一次传递仍因更新min和max的额外工作而内存瓶颈)。然而,这种算法在标准库中并不存在,编写一个算法要比编写计数排序的其余部分本身更麻烦。这是留给读者的练习。
百万千克1百万千克1
由于该算法只适用于整数值,因此可以使用静态断言来防止用户犯明显的类型错误。在某些情况下,使用std::enable_if_t替代失败可能是首选。
百万千克1百万千克1
虽然现代C++很酷,但未来C++更酷:结构绑定和范围TS的某些部分会使算法更干净。
百万千克1
型
- @templaterex如果能够获取任意比较对象,它将使计数排序成为比较排序,并且比较排序的最坏情况不能比O(n log n)更好。计数排序有一个最坏的情况O(N+R),这意味着它无论如何都不能是比较排序。可以比较整数,但此属性不用于执行排序(它仅用于仅收集信息的std::minmax_element中)。所使用的属性是这样一个事实:整数可以用作索引或偏移量,并且在保留后一个属性的同时,它们是可递增的。
- 范围ts确实非常好,例如,最后一个循环可以在counts | ranges::view::filter([](auto c) { return c != 0; })之上,这样您就不必在fill_n内反复测试非零计数。
- (我在small和rather和appart中发现了拼写错误,我可以保留它们直到有关reggae的编辑排序吗?)
- @你可以做任何你想做的事:p
- 我怀疑,与在柱状图绘制之前用minmax_element遍历输入相比,在运行中增加counts[]是一个胜利。特别是对于理想情况下的使用情况,在很小的范围内有很多重复的非常大的输入,因为您将很快地将counts增长到它的全部大小,并且很少有分支预测失误或大小加倍。(当然,知道范围上的足够小的界限可以避免minmax_element扫描,并避免在柱状图循环中进行界限检查。)
- 对于具有大量相同数量连续重复的输入,展开多个counts数组并在最后求和可以在实际CPU上获胜。它会反复隐藏存储/重新加载到同一个bin的延迟。(这是一种对少量垃圾箱的直方图优化方法。)
- @彼得卡德确信,实现并不意味着是一个现实世界的生产就绪的实现,而只是一个简单的实现,可以用templaterex给出的规则编写。如果你觉得读者可能从中获益,可以随意编辑并在"省略的细节"中添加注释。
- 完成。我认为快速成长的counts永远是一条必经之路。这是相同的总工作(模一点复制计数),但融合成一个循环。对于计数排序很好的情况,甚至更好,而对于计数排序不好的情况,则可能稍差(需要用新的min进行大量复制的巨大范围)。