来自 C++ 标准库的 std::sort
算法(及其表亲 std::partial_sort
和 std::nth_element
)在大多数 更基本的算法中是一种复杂、混合的混合算法 如选择排序、插入排序、快速排序、合并排序或堆排序。
这里和姊妹网站上有很多问题,例如 https://codereview.stackexchange.com/ 与这些经典排序算法实现的错误、复杂性和其他方面相关。大多数提供的实现由原始循环、使用索引操作和具体类型组成,并且通常在正确性和效率方面进行分析并非易事。
问题:如何使用现代 C++ 实现上述经典排序算法?
- 没有原始循环,但结合了标准库的算法构建块来自
<algorithm>
- 迭代器接口 和 模板 的使用,而不是索引操作和具体类型
- C++14 风格,包括完整的标准库,以及
auto
等语法降噪器、模板别名、透明比较器和多态 lambda。
备注:
- 有关排序算法实现的进一步参考,请参见 Wikipedia 、 Rosetta Code 或 http://www.sorting-algorithms.com/
- 根据 Sean Parent 的约定(幻灯片 39),原始循环是
for
循环比使用运算符的两个函数组合更长。 Sof(g(x));
orf(x); g(x);
orf(x) + g(x);
are not raw loops, and neither are the loops inselection_sort
andinsertion_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 的形式呈现。在相关的地方,我表示各种语言版本不同的句法和库差异。
原文由 TemplateRex 发布,翻译遵循 CC BY-SA 4.0 许可协议
算法构建块
我们首先从标准库中组装算法构建块:
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<>
形式的 透明比较器,它们多态地作用于它们的参数。这避免了必须提供迭代器的类型。这可以与 C++11 的 默认函数模板参数 结合使用,为将<
作为比较的排序算法和具有用户定义的比较函数对象的排序算法创建 一个重载。在 C++11 中,可以定义一个可重用的 模板别名 来提取迭代器的值类型,这会为排序算法的签名添加轻微的混乱:
在 C++98 中,需要编写两个重载并使用详细的
typename xxx<yyy>::type
语法auto
参数,这些参数被推导出为函数模板参数)。value_type_t
。std::bind1st
/std::bind2nd
/std::not1
类型的语法。boost::bind
和_1
/_2
占位符语法改进了这一点。std::find_if_not
,而 C++98 需要std::find_if
和一个std::not1
围绕一个函数对象。C++ 风格
目前还没有普遍接受的 C++14 风格。无论好坏,我都密切关注 Scott Meyers 的 草稿 Effective Modern C++ 和 Herb Sutter 修改后的 GotW 。我使用以下样式建议:
()
and{}
when creating objects” and consistently choose braced-initialization{}
instead of the good old parenthesized initialization()
(为了回避通用代码中所有最棘手的解析问题)。typedef
可以节省时间并增加一致性。for (auto it = first; it != last; ++it)
模式,以便允许对已排序的子范围进行循环不变检查。在生产代码中,在循环内的某处使用while (first != last)
和++first
可能会稍微好一些。选择排序
选择排序 不会以任何方式适应数据,所以它的运行时间总是
O(N²)
。但是,选择排序具有 最小化交换次数的 特性。在交换项目的成本很高的应用程序中,选择排序非常可能是首选算法。要使用标准库实现它,重复使用
std::min_element
找到剩余的最小元素,并使用iter_swap
将其交换到位:请注意,
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
向上移动剩余元素在输入范围内:请注意,
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 ):
O(N²)
比较,但这改进为O(N)
比较几乎排序的输入。二分查找总是使用O(N log N)
比较。快速排序
仔细实施后, 快速排序 是稳健的,并且具有
O(N log N)
预期的复杂性,但具有O(N²)
最坏情况的复杂性,可以通过对抗性选择的输入数据触发。当不需要稳定排序时,快速排序是一种出色的通用排序。即使对于最简单的版本,使用标准库实现快速排序也比其他经典排序算法要复杂得多。下面的方法使用一些迭代器实用程序来定位输入范围的 中间元素
[first, last)
作为枢轴,然后使用对std::partition
的两次调用(它们是O(N)
) 将输入范围三路划分为分别小于、等于和大于所选枢轴的元素段。最后,对元素小于和大于枢轴的两个外部段进行递归排序:然而,快速排序要获得正确和高效是相当棘手的,因为上述每个步骤都必须仔细检查并针对生产级代码进行优化。特别是,对于
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)
complexity can be achieved through median pivot selection usingstd::nth_element(first, middle, last)
, followed by recursive calls toquick_sort(first, middle, cmp)
andquick_sort(middle, last, cmp)
。O(N)
复杂性的常数因子可能比中位数的std::nth_element
O(1)
复杂性的常数因子更昂贵-of-3 pivot 后跟O(N)
调用std::partition
(这是对数据的缓存友好的单次前向传递)。合并排序
如果使用
O(N)
多余的空间是无关紧要的,那么 归并排序 是一个很好的选择:它是唯一 稳定 的O(N log N)
排序算法。使用标准算法很容易实现:使用一些迭代器实用程序来定位输入范围的中间
[first, last)
并将两个递归排序的段与std::inplace_merge
组合起来:合并排序需要双向迭代器,瓶颈是
std::inplace_merge
。请注意,在对链表进行排序时,归并排序仅需要O(log N)
额外空间(用于递归)。后一种算法由标准库中的std::list<T>::sort
实现。堆排序
堆排序 很容易实现,执行
O(N log N)
就地排序,但不稳定。第一个循环,
O(N)
“heapify”阶段,将数组放入堆中。第二个循环,O(N log N
)“排序”阶段,反复提取最大值并恢复堆顺序。标准库使这变得非常简单:如果您认为使用
std::make_heap
和std::sort_heap
std::pop_heap
“作弊”,您可以更深入一层并根据std::push_heap
自己编写这些函数---
分别为:标准库将
push_heap
和pop_heap
指定为复杂度O(log N)
。 Note however that the outer loop over the range[first, last)
results inO(N log N)
complexity formake_heap
, whereasstd::make_heap
has onlyO(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%)。