C ++标准库中的std::sort
算法(及其堂兄std::partial_sort
和std::nth_element
)在大多数实现中都是a complicated and hybrid amalgamation of more elementary sorting algorithms,例如选择排序,插入排序,快速排序,合并排序或堆排序。
这里和姊妹网站上存在许多问题,例如https://codereview.stackexchange.com/与这些经典排序算法的错误,复杂性和其他方面有关。大多数提供的实现包括原始循环,使用索引操作和具体类型,并且在正确性和效率方面分析通常是非常重要的。
问题:如何使用现代C ++实现上述经典排序算法?
<algorithm>
auto
,模板别名,透明比较器和多态lambda。备注:
for
- 循环比使用运算符的两个函数的组合更长。因此f(g(x));
或f(x); g(x);
或f(x) + g(x);
不是原始循环,下面的selection_sort
和insertion_sort
中的循环也不是。答案 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
value_type_t
参数推导包装用户定义的比较器,这些参数可以像函数模板参数一样推导出来)。 std::bind1st
。 std::bind2nd
/ std::not1
/ boost::bind
类型的语法。 _1
和_2
/ std::find_if_not
占位符语法对此进行了改进。std::find_if
,而C ++ 98需要std::not1
并且函数对象周围有{}
。目前还没有普遍接受的C ++ 14风格。无论好坏,我都会密切关注Scott Meyers的draft Effective Modern C++和Herb Sutter的revamped GotW。我使用以下样式建议:
()
and {}
when creating objects"并始终选择支持初始化()
而不是旧的括号初始化typedef
(为了支持所有最令人烦恼的解析通用代码中的问题)。for (auto it = first; it != last; ++it)
使用它可以节省时间并增加一致性。while (first != last)
模式,以便对已经排序的子范围进行循环不变检查。在生产代码中,在循环内部某处使用++first
和O(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是数据接近排序时的首选算法(因为它是自适应3} strong>)或当问题规模很小时(因为它的开销很低)。由于这些原因,并且因为它也是稳定,插入排序通常被用作递归基本情况(当问题大小很小时),用于更高开销的分而治之的排序算法,例如合并排序或快速排序。
要使用标准库实现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++14,C++11,C++98 and Boost,C++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)
中位数。
详细信息:
1, 2, 3, ..., N/2, ... 3, 2, 1
复杂度。输入O(N^2)
(因为中间总是比所有其他元素都大)。std::partition
。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_heap
和std::sort_heap
,您可以更深入一级,并分别使用std::push_heap
和std::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_heap
和pop_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++14,C++11,C++98 and Boost,C++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 sort,ska_sort或spreadsort。
详细信息:
我们可以将算法接受的值范围的边界作为参数传递,以完全摆脱通过集合的第一个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 bindings而Ranges TS的某些部分会使算法更加清晰。