如何在现代C ++中实现经典的排序算法?


331

在大多数实现中,C ++标准库中的std::sort算法(及其表亲std::partial_sortstd::nth_element)是更多基本排序算法(例如选择排序,插入排序,快速排序,合并排序或堆排序)的复杂混合混合

在这里以及在姐妹网站(例如https://codereview.stackexchange.com/)上,存在许多与这些经典排序算法的错误,复杂性和实现的其他方面有关的问题。提供的大多数实现都是由原始循环,使用索引操作和具体类型组成的,并且从正确性和效率方面来说,通常都是不平凡的分析。

问题:如何使用现代C ++实现上述经典排序算法?

  • 没有原始循环,但结合了标准库的算法构建块<algorithm>
  • 迭代器接口模板的使用,而不是索引操作和具体类型的使用
  • C ++ 14样式,包括完整的标准库以及语法降噪器,例如auto,模板别名,透明比较器和多态lambda。

注意事项

  • 有关排序算法实现的更多参考,请参见WikipediaRosetta Codehttp://www.sorting-algorithms.com/
  • 根据Sean Parent的约定(幻灯片39),原始循环是-循环,for比使用运算符将​​两个函数组成更长。So f(g(x));or f(x); g(x);or f(x) + g(x);不是原始循环,也不是内部selection_sortinsertion_sort下面的循环。
  • 我遵循Scott Meyers的术语将当前的C ++ 1y表示为C ++ 14,并将C ++ 98和C ++ 03都表示为C ++ 98,所以不要为此而烦恼。
  • 正如@Mehrdad的评论中所建议的那样,我在答案的末尾提供了四个作为实时示例的实现:C ++ 14,C ++ 11,C ++ 98和Boost和C ++ 98。
  • 答案本身仅用C ++ 14表示。在相关的地方,我表示的是各种语言版本不同的语法和库差异。

8
向问题中添加C ++ Faq标记将是很棒的,尽管这将需要丢失至少一个其他标记。我建议删除这些版本(因为这是一个通用的C ++问题,在大多数版本中都有一些实现,并且有一些改编)。
Matthieu M.

@TemplateRex好吧,从技术上讲,如果不是FAQ,那么这个问题就太广泛了(猜测-我没有投票)。顺便说一句。干得好,很多有用的信息,谢谢:)
BartoszKP 2014年

Answers:


388

算法构建块

我们从组装标准库的算法构建块开始:

#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
  • 迭代器工具(例如non-member std::begin()/ std::end()以及with)std::next()仅在C ++ 11及更高版本中可用。对于C ++ 98,需要自己编写这些。有替代品从Boost.Range的boost::begin()/ boost::end(),并从Boost.Utility中boost::next()
  • std::is_sorted算法仅适用于C ++ 11及更高版本。对于C ++ 98,这可以通过std::adjacent_find手写功能对象来实现。Boost.Algorithm还提供了boost::algorithm::is_sorted一个替代项。
  • std::is_heap算法仅适用于C ++ 11及更高版本。

语法优势

C ++ 14提供了透明的比较器,其形式std::less<>对它们的参数具有多态作用。这样避免了必须提供迭代器的类型。可以将其与C ++ 11的默认函数模板参数结合使用,以创建单个重载,以对<作为比较的排序算法和具有用户定义的比较函数对象的算法进行排序。

template<class It, class Compare = std::less<>>
void xxx_sort(It first, It last, Compare cmp = Compare{});

在C ++ 11中,可以定义一个可重用的模板别名来提取迭代器的值类型,这会给排序算法的签名增加些许混乱:

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中,需要编写两个重载并使用详细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>());
}
  • 另一个语法上的好处是C ++ 14有助于通过多态lambda(带有auto像函数模板参数一样推导的参数)包装用户定义的比较器。
  • C ++ 11仅具有单态lambda,需要使用上述模板alias value_type_t
  • 在C ++ 98,一个或者需要编写一个独立的功能对象或诉诸冗长std::bind1st/ std::bind2nd/ std::not1类型语法。
  • Boost.Bind使用boost::bind_1/ _2占位符语法对此进行了改进。
  • C ++ 11及更高版本也有std::find_if_not,而C ++ 98需要std::find_if带有std::not1一个功能对象。

C ++风格

目前尚无普遍接受的C ++ 14样式。不管是好是坏,我都密切关注Scott Meyers的草案有效的现代C ++和Herb Sutter 修改后的GotW。我使用以下样式建议:

  • 赫伯·萨特(Herb Sutter)的“几乎总是自动”和斯科特·迈耶斯(Scott Meyers)的“首选自动使用特定类型声明”的建议,尽管有时它的清晰度有时会引起争议,但其简洁性并没有超越。
  • 斯科特·迈耶斯(Scott Meyers)的“区分(){}创建对象时”,始终选择支撑初始化{}而不是旧的括号初始化()(以避开通用代码中所有最烦人的解析问题)。
  • 斯科特·迈耶斯(Scott Meyers)的“首选别名声明而不是typedefs”。对于模板而言,无论如何都是必须的,并且在各处使用它而不是typedef节省时间和增加一致性。
  • for (auto it = first; it != last; ++it)在某些地方使用了模式,以允许对已经排序的子范围进行循环不变检查。在生产代码中,在循环内使用while (first != last)++first可能会更好。

选择排序

选择排序不会以任何方式适应数据,因此其运行时间始终为O(N²)。但是,选择排序具有使交换次数最小化的属性。在交换项目成本很高的应用程序中,很好的选择排序可能是选择的算法。

要使用标准库来实现它,请重复使用std::min_element以查找剩余的最小元素,并将iter_swap其交换到位:

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(N²)最坏情况下的基本排序算法之一,但是插入排序是在数据接近排序时(因为它是自适应的)或在问题大小较小时(因为它的开销很低)选择的算法。由于这些原因,并且由于它也是稳定的,因此通常将插入排序用作递归基本案例(问题大小较小时),以用于开销较大的分治式排序算法,例如合并排序或快速排序。

insertion_sort使用标准库来实现,请反复使用std::upper_bound来查找当前元素所需的位置,并使用std::rotate来在输入范围内向上移动其余元素:

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 ++ 14C ++ 11C ++ 98和BoostC ++ 98):

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(N²),可以进行O(N)比较,但是可以改善几乎排序的输入的比较。二进制搜索始终使用O(N log N)比较。
  • 对于较小的输入范围,线性搜索的更好的内存位置(高速缓存,预取)也可能会主导二进制搜索(当然应该对它进行测试)。

快速分类

如果仔细实施,快速排序将很可靠并且具有O(N log N)预期的复杂性,但O(N²)最坏情况下的复杂性可以由对抗性选择的输入数据触发。当不需要稳定排序时,快速排序是一种出色的通用排序。

即使是最简单的版本,使用标准库实现的快速排序也比其他经典的排序算法要复杂得多。下面的方法使用一些迭代器实用程序将输入范围的中间元素定位[first, last)为枢轴,然后使用两次调用std::partition(分别为O(N))将输入范围三倍划分为小于,等于,和分别大于选定的枢轴。最后,递归地对元素小于和大于枢轴的两个外部段进行递归排序:

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)输入范围的中位数则可以保证这一点。

详细信息省略

  • 上述实现特别容易受到特殊输入的影响,例如,它O(N^2)对于“ 风琴 ”输入具有复杂性1, 2, 3, ..., N/2, ... 3, 2, 1(因为中间总是大于所有其他元素)。
  • 从输入范围的随机选择的元素中选择 3个枢轴的中值可防止对输入进行排序,否则这些输入的复杂度将恶化为O(N^2)
  • 如两次调用所示,三向分区(分隔小于,等于和大于枢轴的元素)std::partition并不是O(N)实现此结果的最有效算法。
  • 对于随机访问迭代器O(N log N)通过使用中位枢轴选择std::nth_element(first, middle, last),然后递归调用quick_sort(first, middle, cmp)和可以实现保证的复杂性quick_sort(middle, last, cmp)
  • 但是,此保证要付出一定的代价,因为的O(N)复杂度的恒定因素std::nth_element可能比O(1)3位中值枢轴点和随后的O(N)调用std::partition(这是对缓存友好的单向传递)的复杂度要高得多。数据)。

合并排序

如果O(N)不需要使用多余的空间,则合并排序是一个不错的选择:它是唯一稳定的 O(N log N)排序算法。

使用标准算法可以很容易地实现:使用一些迭代器实用程序来定位输入范围的中间,[first, last)并将两个递归排序的段与组合在一起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));
}

合并排序需要双向迭代器,瓶颈是std::inplace_merge。请注意,在对链接列表进行排序时,合并排序仅需要O(log N)额外的空间(用于递归)。后一种算法是std::list<T>::sort在标准库中实现的。

堆排序

堆排序易于实现,执行O(N log N)就地排序,但不稳定。

第一个循环为O(N)“堆”阶段,将数组置于堆顺序。第二个循环(O(N log N)“ sortdown”阶段反复提取最大值并恢复堆顺序。标准库使这一过程变得非常简单:

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_heapstd::pop_heap分别根据和编写这些函数:

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_heappop_heap作为复杂性O(log N)。但是请注意,超出范围的外部循环会[first, last)导致的O(N log N)复杂性make_heap,而std::make_heap仅具有O(N)复杂性。对于整体O(N log N)复杂性而言,heap_sort这无关紧要。

省略细节O(N)实现make_heap

测验

这是四个实时示例C ++ 14C ++ 11C ++ 98和BoostC ++ 98),它们在各种输入上测试所有五种算法(并非详尽无遗或严格)。只需注意LOC的巨大差异:C ++ 11 / C ++ 14需要大约130 LOC,C ++ 98和Boost 190(+ 50%),而C ++ 98则需要270(+ 100%)。


13
虽然我不同意您的使用auto(很多人不同意我的看法),但我很高兴看到标准库算法使用得很好。在看完Sean Parent的演讲后,我一直想看一些此类代码的示例。另外,我不知道该怎么std::iter_swap办,尽管对我来说很奇怪<algorithm>
约瑟夫·曼斯菲尔德

32
@sbabbi整个标准库都是基于迭代器便宜复制的原则。例如,它按值传递它们。如果复制迭代器并不便宜,那么到处都会遇到性能问题。
James Kanze 2014年

2
很棒的帖子。关于[std ::] make_heap的作弊部分。如果std :: make_heap被认为是作弊行为,那么std :: push_heap也会被作弊。即作弊=未实现为堆结构定义的实际行为。我也会发现push_heap也很有启发性。
长颈鹿队长2014年

3
@gnzlbg当然可以断言的断言。可以按迭代器类别按标签分派早期测试,而当前版本为随机访问,并且if (first == last || std::next(first) == last)。我可能稍后再更新。在“忽略的细节”部分中实施这些内容超出了IMO的讨论范围,因为它们包含指向整个Q&A本身的链接。实现实词排序例程非常困难!
TemplateRex

3
很棒的帖子。不过,nth_element在我看来,您已经欺骗了您的quicksort 。nth_element已经完成了一半的快速排序(包括分区步骤和对包含您感兴趣的第n个元素的一半的递归)。
sellibitze 2014年

14

最初在代码审查中发现的另一个小巧而优雅的代码。我认为值得分享。

计数排序

尽管计数排序非常专业,但计数排序是一种简单的整数排序算法,并且如果要排序的整数的值相距不远,通常可以非常快。如果有人需要对一百万个已知在0到100之间的整数进行排序,那可能是理想的选择。

为了实现一种非常简单的计数排序,该计数排序可以同时处理有符号和无符号整数,因此需要在集合中找到要进行排序的最小和最大元素。它们的区别将告诉您要分配的计数数组的大小。然后,进行第二次收集,以计算每个元素的出现次数。最后,我们将所需的每个整数数写回到原始集合中。

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++);
    }
}

虽然仅当已知要排序的整数的范围较小(通常不大于要排序的集合的大小)时才有用,但是对排序进行更通用的计数将使其在最佳情况下变慢。如果不知道范围很小,则可以改用其他算法,例如基数sortska_sortspreadsort

详细信息省略

  • 我们可以传递算法接受的值范围的边界作为参数,以完全摆脱std::minmax_element通过集合的第一次传递。当通过其他方式知道有用的小范围限制时,这将使算法更快。(这不一定是精确的;将常数0传递给100仍然比对一百万个元素进行一次额外传递好得多,因为它可以发现真实界限是1到95。甚至0到1000也是值得的;多余的元素以0写入一次,然后读取一次)。

  • 快速增长counts是避免单独的第一遍的另一种方法。counts每次增长时将其大小加倍,就可以为每个排序的元素分摊O(1)时间(请参见哈希表插入成本分析以了解指数增长是关键的证据)。添加新的置零元素max很容易在最后std::vector::resize增加新元素。增长矢量后,min可以随时进行更改并在前面插入新的清零元素std::copy_backward。然后std::fill将新元素清零。

  • counts增量环是直方图。如果数据可能具有很高的重复性,并且bin的数量很少,则值得在多个阵列展开以减少存储/重新加载到同一bin的序列化数据依赖性瓶颈。这意味着从一开始到零的计数更多,而在末尾有更多的计数循环,但是对于我们数以百万计的0到100的数字示例,在大多数CPU上都值得这样做,特别是如果输入可能已经(部分)排序并具有相同编号的长距离运行。

  • 在上面的算法中,min == max当每个元素具有相同的值(在这种情况下,对集合进行排序)时,我们使用检查以尽早返回。相反,实际上有可能在查找集合的极值的同时完全检查集合是否已经排序,而不会浪费额外的时间(如果第一次遍历仍然是内存的瓶颈,需要进行更新最小和最大的额外工作)。但是,这样的算法在标准库中不存在,编写一个比编写其余的计数排序本身要麻烦得多。它留给读者练习。

  • 由于该算法仅适用于整数值,因此可以使用静态断言来防止用户犯明显的类型错误。在某些情况下,替换失败std::enable_if_t可能是首选。

  • 尽管现代C ++很酷,但未来的C ++可能更酷:结构化绑定Ranges TS的某些部分将使算法更加清晰。


@TemplateRex如果能够采用任意比较对象,则它将使计数排序成为比较排序,并且比较排序的最坏情况不会比O(n log n)更好。计数排序的最坏情况是O(n + r),这意味着无论如何它都不是比较排序。可以比较整数,但是不使用此属性执行排序(仅用于std::minmax_element仅收集信息的)。使用的属性是整数可以用作索引或偏移量,并且在保留后者的属性时可以递增。
Morwenn

范围TS确实非常好,例如最终循环可以结束,counts | ranges::view::filter([](auto c) { return c != 0; })因此您不必重复测试内的非零计数fill_n
TemplateRex

(我发现了small 一个 拼写错误,rather并且appart-我可以保留它们直到进行有关reggae_sort的编辑吗?)
greybeard

@greybeard你可以做任何你想做的事:p
Morwenn

我怀疑,动态增长counts[]minmax_element直方图转换之前遍历输入将是双赢。特别是对于理想的用例来说,它的输入量很大,并且在很小的范围内有很多重复,因为您很快就会成长counts为完整大小,几乎没有分支错误预测或大小加倍的情况。(当然,知道范围的小界限将使您避免minmax_element扫描避免在直方图循环内进行界限检查。)
Peter Cordes
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.