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

时间:2014-07-09 09:59:14

标签: c++ algorithm sorting c++14 c++-faq

C ++标准库中的std::sort算法(及其堂兄std::partial_sortstd::nth_element)在大多数实现中都是a complicated and hybrid amalgamation of more elementary sorting algorithms,例如选择排序,插入排序,快速排序,合并排序或堆排序。

这里和姊妹网站上存在许多问题,例如https://codereview.stackexchange.com/与这些经典排序算法的错误,复杂性和其他方面有关。大多数提供的实现包括原始循环,使用索引操作和具体类型,并且在正确性和效率方面分析通常是非常重要的。

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

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

备注

  • 有关排序算法实施的进一步参考,请参阅WikipediaRosetta Codehttp://www.sorting-algorithms.com/
  • 根据Sean Parent's conventions(幻灯片39),原始循环是for - 循环比使用运算符的两个函数的组合更长。因此f(g(x));f(x); g(x);f(x) + g(x);不是原始循环,下面的selection_sortinsertion_sort中的循环也不是。
  • 我遵循Scott Meyers的术语来表示当前的C ++ 1y已经作为C ++ 14,并且将C ++ 98和C ++ 03都表示为C ++ 98,所以不要&#39为了那个火焰我。
  • 正如@Mehrdad的评论中所建议的那样,我在答案的最后提供了四个实现作为实例:C ++ 14,C ++ 11,C ++ 98和Boost and C ++ 98。
  • 答案本身仅以C ++ 14的形式呈现。在相关的情况下,我表示各种语言版本不同的语法和库差异。

2 个答案:

答案 0 :(得分:374)

算法构建块

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

#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
  • 非成员std::begin() / std::end()以及std::next()等迭代器工具仅在C ++ 11及更高版本中可用。对于C ++ 98,人们需要自己编写。来自boost::begin() / boost::end()的Boost.Range和boost::next()中的Boost.Utility都有替代品。
  • 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<> <形式的transparent comparators形式,它们的参数多态化。这避免了必须提供迭代器类型。这可以与C ++ 11 default function template arguments结合使用来创建单个重载,用于排序算法,将template<class It, class Compare = std::less<>> void xxx_sort(It first, It last, Compare cmp = Compare{}); 作为比较,将具有用户的算法作为比较 - 定义比较函数对象。

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 ++ 11中,可以定义一个可重用的template alias来提取迭代器的值类型,这会给排序算法带来轻微的混乱。签名:

typename xxx<yyy>::type

在C ++ 98中,需要编写两个重载并使用详细的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>()); } 语法

auto
  • 另一个语法准确性是C ++ 14通过多态lambdas (使用value_type_t参数推导包装用户定义的比较器,这些参数可以像函数模板参数一样推导出来)。
  • C ++ 11只有单形lambda,需要使用上面的模板别名std::bind1st
  • 在C ++ 98中,要么需要编写独立的函数对象,要么使用冗长的std::bind2nd / std::not1 / boost::bind类型的语法。
  • Boost.Bind通过_1_2 / std::find_if_not占位符语法对此进行了改进。
  • C ++ 11及更高版本也有std::find_if,而C ++ 98需要std::not1并且函数对象周围有{}

C ++ Style

目前还没有普遍接受的C ++ 14风格。无论好坏,我都会密切关注Scott Meyers的draft Effective Modern C++和Herb Sutter的revamped GotW。我使用以下样式建议:

  • Herb Sutter的"Almost Always Auto"和Scott Meyers的"Prefer auto to specific type declarations"推荐,其简洁性是无与伦比的,尽管其清晰度有时是disputed
  • Scott Meyers的"Distinguish () and {} when creating objects"并始终选择支持初始化()而不是旧的括号初始化typedef(为了支持所有最令人烦恼的解析通用代码中的问题)。
  • Scott Meyers的"Prefer alias declarations to typedefs"。对于模板,无论如何这是必须的,并且在任何地方而不是for (auto it = first; it != last; ++it)使用它可以节省时间并增加一致性。
  • 我在某些地方使用while (first != last)模式,以便对已经排序的子范围进行循环不变检查。在生产代码中,在循环内部某处使用++firstO(N²)可能会稍好一些。

选择排序

Selection sort无法以任何方式适应数据,因此其运行时始终为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最坏情况时间的基本排序算法之一,但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上的循环进行优化,因为第一个元素可以保证在适当的位置,不需要旋转。
  • 对于双向迭代器,使用标准库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(); 可以使用反向线性搜索替换找到插入点的二进制搜索算法。

以下片段的四个实时示例C++14C++11C++98 and BoostC++98):

O(N²)
  • 对于随机输入,这会给出O(N)比较,但这会改进几乎排序输入的O(N log N)比较。二进制搜索始终使用O(N log N)比较。
  • 对于较小的输入范围,线性搜索的更好的内存位置(缓存,预取)也可能在二进制搜索中占主导地位(当然,应该对此进行测试)。

快速排序

仔细实施后,quick sort功能强大且预期复杂度为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)中位数。

详细信息

  • 上述实施特别容易受到特殊输入的影响,例如:对于&#34; 风琴管&#34;它有1, 2, 3, ..., N/2, ... 3, 2, 1复杂度。输入O(N^2)(因为中间总是比所有其他元素都大)。
  • median-of-3来自输入范围的randomly chosen elements的数据透视选择防范几乎已排序的输入,否则复杂性会恶化到std::partition
  • 3-way partitioning(分隔小于,等于和大于枢轴的元素),如对O(N)的两次调用所示,并不是实现此结果的最有效O(N log N)算法。
  • 对于随机访问迭代器,可以通过使用std::nth_element(first, middle, last)中值数据透视选择来实现保证quick_sort(first, middle, cmp)复杂性,然后递归调用{ {1}}和quick_sort(middle, last, cmp)
  • 此保证是有代价的,因为O(N) std::nth_element复杂度的常数因素可能比O(1)复杂度的中位数更高。 3枢轴,然后O(N)调用std::partition(这是对数据的缓存友好的单向前传递。)

合并排序

如果使用O(N)额外空间无关紧要,那么merge sort是一个很好的选择:它是唯一的稳定 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实现。

堆排序

Heap sort很容易实现,执行O(N log N)就地排序,但不稳定。

第一个循环,O(N)&#34;堆积&#34;阶段,将数组放入堆顺序。第二个循环,O(N log N)&#34;排序&#34;阶段,重复提取最大值并恢复堆顺序。标准库使这非常简单:

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

如果你认为它&#34;作弊&#34;要使用std::make_heapstd::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)的{​​{1}}复杂度,而make_heap仅具有std::make_heap复杂度。对于O(N) O(N log N)的总体heap_sort复杂度,它并不重要。

详细信息O(N) implementation of make_heap

测试

以下是四个实时示例C++14C++11C++98 and BoostC++98)测试各种输入上的所有五种算法(并不意味着详尽或严谨)。请注意LOC中的巨大差异:C ++ 11 / C ++ 14需要大约130 LOC,C ++ 98和Boost 190(+ 50%)以及C ++ 98大于270(+ 100%)。 / p>

答案 1 :(得分:14)

另一个小而优雅的originally found on code review。我认为值得分享。

计算排序

虽然它是相当专业的,但counting sort是一个简单的整数排序算法,如果要排序的整数值不是太远,通常可以非常快。如果有人需要对已知在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++);
    }
}

虽然只有在要排序的整数范围很小(通常不大于要排序的集合的大小)时才有用,但使计数排序更通用会使其在最佳情况下变慢。如果未知范围较小,则可以使用其他算法,例如radix sortska_sortspreadsort

详细信息

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

  • 动态增长counts是避免单独第一次通过的另一种方法。每次必须增加counts大小加倍,每个排序元素的停顿时间为O(1)(参见散列表插入成本分析,指数增长的证据是关键)。通过max添加新的归零元素,可以轻松增加新的std::vector::resize。 在生长向量后,可以使用min在运行中更改std::copy_backward并在前面插入新的归零元素。然后std::fill将新元素归零。

  • counts增量循环是直方图。如果数据可能是高度重复的,并且bin的数量很少,则值unrolling over multiple arrays可以将存储/重新加载的序列化数据依赖性瓶颈减少到相同的bin。这意味着在开始时更多计数为零,并且在结束时更多地进行循环,但对于我们的数百万个0到100数字的示例,在大多数CPU上应该是值得的,特别是如果输入可能已经(部分)排序并且长期运行相同的号码。

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

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

  • 虽然现代C ++很酷,但未来的C ++可能会更酷:structured bindingsRanges TS的某些部分会使算法更加清晰。