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

How to implement classic sorting algorithms in modern C++?

从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_sortinsertion_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


算法构建块

我们首先从标准库组装算法构建块:好的。

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_elementO(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_heapstd::sort_heap是"欺骗",您可以更深入一个层次,分别根据std::push_heapstd::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_heappop_heap都指定为复杂性O(log N)。但是请注意,在[first, last)范围内的外循环导致make_heapO(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%)。好的。好啊。


另一个小的,相当优雅的,最初在代码评审中发现的。我认为这值得分享。

计数排序

虽然计数排序相当专业,但它是一种简单的整数排序算法,如果要排序的整数的值相距不太远,则计数排序通常会非常快。例如,如果需要对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