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

346

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循环。因此,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,所以请不要因此而批评我。
  • 如@Mehrdad的评论所建议的,我在答案末尾提供了四个实现的实时示例:C++14、C++11、C++98和Boost和C++98。
  • 本答案仅以C++14为基础呈现。在相关情况下,我会标注各种语言版本之间的语法和库差异。

8
尽管这需要移除至少一个标签,但将C++Faq标签添加到问题中会很好。我建议删除版本标签(因为这是一般的C++问题,在大多数版本中都有可用的实现,只需要进行一些调整)。 - Matthieu M.
@TemplateRex 嗯,从技术上讲,如果这不是常见问题,那么这个问题就太宽泛了(猜测-我没有投反对票)。顺便说一句,做得好,提供了很多有用的信息,谢谢 :) - BartoszKP
3个回答

407

算法构建模块

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

#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.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,需要使用上面的模板别名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::not1中的std::find_if

C++风格

目前还没有被广泛接受的C++14风格。无论好坏,我紧密地遵循Scott Meyers的《Effective Modern C++》草案和Herb Sutter的改版GotW。我使用以下样式建议:

选择排序

选择排序 不会根据数据进行任何适应,因此其运行时间始终为 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++14, C++11, C++98和Boost, C++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)中位数,则可以保证。
细节已省略:
上述实现特别容易受到特殊输入的攻击,例如对于“organ pipe”输入1、2、3、…、N/2、…3、2、1,其复杂度为O(N^2)(因为中间元素始终大于所有其他元素)。从随机选择的元素中选取,中位数的枢轴选择可防范近乎排序的输入,否则其复杂度会恶化到O(N^2)。如两次调用std::partition所示,三路分区(将小于、等于和大于枢轴的元素分开)不是实现此结果的最有效算法O(N)。对于随机访问迭代器,通过使用std::nth_element(first, middle, last)选择中位数枢轴,然后递归调用quick_sort(first, middle, cmp)和quick_sort(middle, last, cmp),可以保证O(NlogN)的复杂度。然而,这种保证是有代价的,因为std::nth_element的O(N)复杂度的常数因子可能比中位数枢轴的O(1)复杂度加上单向遍历数据的缓存友好的std::partition的O(N)调用更昂贵。

归并排序

如果不考虑使用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)的“heapify”阶段,将数组放入堆顺序。第二个循环,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_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)上的外部循环导致make_heap的复杂度为O(N log N),而std::make_heap的复杂度仅为O(N)。对于heap_sort的总体O(N log N)复杂度并不重要。 细节省略make_heapO(N)实现

测试

这里有四个实时示例(C++14, C++11, C++98和Boost, C++98),测试了所有五种算法在各种输入上的表现(不旨在全面或严格)。请注意代码行数方面的巨大差异:C++11 / C++14 需要约130行代码,C++98和Boost需要190行代码(增加了50%),而C++98则需要超过270行代码(增加了100%)。


13
虽然我不同意你使用auto(许多人也不同意我的观点),但我喜欢看到标准库算法被很好地运用。在看了Sean Parent的演讲后,我一直想看到这种代码的例子。此外,我之前并不知道std::iter_swap的存在,尽管它出现在<algorithm>中对我来说有些奇怪。 - Joseph Mansfield
33
整个标准库都基于一个原则,即认为迭代器的复制成本很低,因此它通过值传递它们。例如,如果复制迭代器的成本很高,那么你将在各个方面遇到性能问题。 - James Kanze
3
你可以将asserts注释掉。早期测试可以按迭代器类别进行标签分发,当前版本针对随机访问,并且包含 if (first == last || std::next(first) == last)。我可能会稍后更新它。在我的看法中,“省略细节”部分的实现超出了问题的范围,因为它们包含自己的整个问答链接。实现真实世界的排序算法很难! - TemplateRex
3
不错的文章。不过,以我之见,你在快速排序中使用了nth_element,有点作弊了。nth_element已经完成了一半的快速排序(包括分割步骤和递归处理所包含要查找的第n个元素的那一半)。 - sellibitze
2
@DavidStone 当然,你是正确的。这个问答并不意味着成为编写实际优化排序例程的明确指南,而是展示如何组合基本构建块。例如,请参阅 cpp-sort 库,了解在实际情况下需要仔细注意多少额外细节 :) - TemplateRex
显示剩余46条评论

16

这是另一个小而优雅的例子最初在代码审查中找到。我认为值得分享。

计数排序

计数排序虽然相对专业化,但它是一种简单的整数排序算法,并且通常可以非常快速地排序整数值不太远离的集合。例如,如果需要对已知介于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++);
    }
}

当需要排序的整数范围已知且不大于要排序的集合大小时,计数排序才有用。如果将计数排序变得更加通用,它在最佳情况下的速度会变慢。如果整数范围未知或较大,则可以使用其他算法,例如基数排序, ska_sortspreadsort

细节已省略:

我们可以将算法参数的范围界限传递给函数,以完全摆脱对集合进行第一次std::minmax_element遍历。如果其他方式已知有用的小范围限制,则这将使算法更快。 (它不必是精确的; 将常数0到100传递仍然比额外遍历一百万个元素找出真实范围为1到95好得多。即使是0到1000也值得; 额外的元素写入零一次并读取一次)。
动态增长counts是避免单独第一遍历的另一种方法。每次需要增长时将counts大小翻倍,每个排序元素的分摊时间为O(1)(有关指数增长的证明,请参见哈希表插入成本分析)。使用std::vector::resize在末尾添加新的零元素以获取新的max很容易。
在增大向量后,可以通过在前面插入新的零元素并使用std::copy_backward来更改min。然后使用std::fill将新元素设置为零。 counts增量循环是直方图。如果数据可能高度重复,并且箱数很少,则通过在多个数组上展开可以减少串行化数据依赖瓶颈,从而值得。这意味着需要在开头将更多的计数归零,并且需要在末尾循环更多的次数,但对于我们的数百万0到100的例子,对于大多数CPU来说应该是值得的,特别是如果输入可能已经(部分)排序并具有长时间运行相同数字。
在上面的算法中,我们使用min == max检查,在每个元素具有相同值的情况下提前返回(在这种情况下,集合已排序)。实际上,可以同时检查集合是否已排序,而无需浪费额外的时间(如果第一遍仍然受到更新min和max的额外工作的内存瓶颈限制)。但是这样的算法不存在于标准库中,并编写一个这样的算法比编写计数排序的其他部分更加繁琐。它留给读者作为练习。
由于该算法仅适用于整数值,因此可以使用静态断言防止用户犯简单的类型错误。在某些上下文中,可能更喜欢使用std::enable_if_t的替换失败。
虽然现代C++很酷,但未来的C++可能会更酷:结构化绑定Ranges TS的某些部分将使算法变得更加简洁。

如果它能够接受任意的比较对象,那么计数排序就会成为一种比较排序,而比较排序的最坏情况不能比O(n log n)更好。计数排序的最坏情况是O(n + r),这意味着它无论如何都不能成为比较排序。整数可以进行比较,但这个属性并没有用于执行排序(它仅在std::minmax_element中用于收集信息)。使用的属性是整数可以用作索引或偏移量,并且它们是可递增的同时保持后者的属性。 - Morwenn
@老灰须,你可以随心所欲地做任何事情 :p - Morwenn
对于存在大量连续重复相同数字的输入,通过多个“counts”数组展开并在最后求和可以在实际CPU上获得优势。它隐藏了重复存储/重新加载到相同bin的延迟。(这是一种针对少量bin的某种众所周知的直方图优化技巧。) - Peter Cordes
@PeterCordes 当然,那个实现并不是为了成为真正的生产就绪的实现,而只是一个简单的实现,可以按照TemplateRex给出的规则编写。如果您认为读者可能会从中受益,请随意编辑和添加“省略的详细信息”注释。 - Morwenn
我认为动态增长counts总是最好的选择。这样做的总工作量相同(除了一些计数的复制),但融合成一个循环。对于计数排序适用的情况来说,这甚至更好,而对于计数排序不适用的情况来说可能略微劣一些(需要大量复制新的最小值的巨大范围)。 - Peter Cordes
显示剩余3条评论

0

Visual Studio 2015之前的版本在链表排序时使用自底向上的归并排序,但当它被更改为使用基于迭代器的算法处理无默认分配器时,转而使用效率较低(由于扫描列表以通过std::next拆分它们)的自顶向下的链表归并排序,这也提供了异常安全性,如果比较(用户定义)抛出异常(在旧版本中,比较异常会丢失内部数组中的列表部分)。当这首次受到质疑时,我认为实现基于迭代器的自底向上的链表归并排序存在问题,但后来确定没有问题。我的实现中唯一“聪明”的部分是使用std::list::end()(一个常量)来表示空子列表(因为迭代器不能为null)。链接到我的答案,显示std::list::sort()的独立和替换代码:

https://dev59.com/6VkR5IYBdhLWcg3w7hbE#40629882


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接