【问题标题】:Implement quicksort on bi-directional iterators在双向迭代器上实现快速排序
【发布时间】:2016-02-03 07:16:27
【问题描述】:

使用具有 O(NlgN) 时间和 O(lgN) 空间的双向迭代器来实现快速排序似乎很简单。那么,std::sort() 需要随机访问迭代器的具体原因是什么?

我已阅读主题why do std::sort and partial_sort require random-access iterators?。但它没有解释可能的std::sort() 实现的具体部分实际上可能需要随机访问迭代器来维持其时间和空间复杂性。

O(NlgN) 时间和 O(lgN) 空间的可能实现:

template <typename BidirIt, typename Pred>
BidirIt partition(BidirIt first, BidirIt last, Pred pred) {
  while (true) {
    while (true) {
      if (first == last) return first;
      if (! pred(*first)) break;
      ++first;
    }
    while (true) {
      if (first == --last) return first;
      if (pred(*last)) break;
    }
    iter_swap(first, last);
    ++first;
  }
}

template <typename BidirIt, typename Less = std::less<void>>
void sort(BidirIt first, BidirIt last, Less&& less = Less{}) {
  using value_type = typename std::iterator_traits<BidirIt>::value_type;
  using pair = std::pair<BidirIt, BidirIt>;
  std::stack<pair> stk;
  stk.emplace(first, last);
  while (stk.size()) {
    std::tie(first, last) = stk.top();
    stk.pop();
    if (first == last) continue;
    auto prev_last = std::prev(last);
    auto pivot = *prev_last;
    auto mid = ::partition(first, prev_last,
      [=](const value_type& val) {
        return val < pivot;
      });
    std::iter_swap(mid, prev_last);
    stk.emplace(first, mid);
    stk.emplace(++mid, last);
  }
}

【问题讨论】:

  • 你真的试过了吗?
  • @Drop 问题已更新。我想知道具体情况。不是一般的答案。
  • 引用:To use sequential iterators would come at a O(N) memory cost to store all of those iterators。这足够具体吗?
  • 但是你的std::stack&lt;pair&gt; stk;有多大?

标签: c++ algorithm iterator quicksort c++-standard-library


【解决方案1】:

实用库排序函数需要随机访问迭代器的原因有几个。

最明显的一点是众所周知的事实,即如果数据已排序(或“大部分已排序”),则为枢轴选择分区的端点会将快速排序减少到 O(n2) ,所以大多数现实生活中的快速排序实际上使用了更强大的算法。我认为最常见的是 Wirth 算法:选择分区的第一个、中间和最后一个元素的中值,这对排序向量很稳健。 (正如 Dieter Kühl 指出的那样,只选择中间元素几乎同样有效,但实际上三中位数算法没有额外成本。)选择随机元素也是一个很好的策略,因为它更难玩游戏,但对 PRNG 的要求可能令人沮丧。除了采用端点之外,任何选择枢轴的策略都需要随机访问迭代器(或线性扫描)。

其次,当分区较小时,快速排序是次优的(对于较小的一些启发式定义)。当元素足够少时,插入排序的简化循环与引用的局部性相结合将使其成为更好的解决方案。 (这不会影响整个算法的复杂性,因为阈值是固定大小的;对于任何先前建立的k,插入最大 k 元素的排序是 O(1)。我想你通常会发现值在 10 到 30 之间。)插入排序可以使用双向迭代器完成,但不能确定分区是否小于阈值(同样,除非您使用不必要的慢循环)。

第三,可能也是最重要的一点,无论你多么努力,快速排序都可以退化为 O(n2)。早期的 C++ 标准接受 std::sort 可能是“平均而言为 O(n log n)”,但由于接受 DR713,该标准要求 std::sort 为 O(n log n) 而没有资格。这不能通过纯快速排序来实现,因此现代库排序算法实际上是基于introsort 或类似的。如果该算法检测到分区过于偏颇,则该算法会退回到不同的排序算法——通常是堆排序。回退算法很可能需要随机访问迭代器(例如,heapsort 和 shellsort 都需要)。

最后,递归深度可以通过使用在最小分区上递归和在较大分区上尾递归(显式循环)的简单策略减少到最大 log2n。由于递归通常比显式维护堆栈要快,并且如果最大递归深度在低两位数内,递归是完全合理的,这个小优化是值得的(尽管并非所有库实现都使用它。)同样,这需要能够计算分区的大小。

实际排序的其他方面可能需要随机访问迭代器;这些只是我的想法。

【讨论】:

  • 我猜可以使用std::advance() 实现三中位数策略,而不会降低时间复杂度,尽管它确实会增加时间常数。但这是另一回事。
  • @lingxi:与理论排序相反,实用排序不会一次计算一个元素,只是为了避免使用几乎可以肯定存在的随机访问迭代器:) 我努力避免做出判断关于标准库实现中的学术练习的陈述,但我相信你可以猜出思维模式。
  • 所以,基本上,有一个较低的时间常数是答案?我真的很怀疑。
  • @lingxi:排序函数是根据它们的速度来判断的。信不信由你。此外,由于关于 C++11,std::sort 需要为 O(n log n),而不仅仅是随机 O(n log n),因为 introsort 被认为是必需的。所以纯快速排序不能用作 std::sort 的一致实现。我将在早上将其添加到答案中,以及一夜之间出现的其他任何问题:)
  • 投了赞成票。我想把这个问题保持一段时间,希望得到一个绝对的答案。
【解决方案2】:

简单的答案是快速排序很慢,除非特别针对小范围进行了优化。为了检测范围很小,需要一种有效的方法来确定它们的大小。

我有一个演示文稿 (here are the slides and the code),其中展示了用于创建快速排序的快速实现的步骤。原来排序实现其实是一种混合算法。

使quicksort 快速的基本步骤如下:

  1. 防范 [大部分] 排序序列。这里有趣的情况之一实际上是由所有相同元素组成的特殊排序序列:在实际数据中,相等的子序列并不罕见。这样做的方法是监视快速排序何时做太多工作并切换到具有已知复杂性的算法,例如heapsortmergesort 以完成对有问题的子序列的排序。这种方法的名称为introsort
  2. 快速排序在短序列上非常糟糕。由于快速排序是divide and conquer algorithm,它实际上会产生许多小序列。例如,可以使用insertionsort 处理小序列。要确定一个序列是否很小,有必要有效地检查序列的大小。这就是需要随机访问的地方。
  3. 虽然它们的影响总体上小于上述方法的影响,但仍有许多额外的优化是使快速排序真正快速所必需的。例如:

    • 使用的分区需要利用哨兵来减少比较次数。
    • 观察分区是否有任何工作可以通过赌注运行插入排序而导致提前纾困,该插入排序会在工作过多时停止。
    • 使用中点而不是序列的任一端来增加前一个点被排序为枢轴的机会有一个优势(这也需要随机访问,但这是一个相对较小的原因)。

我还没有做过实验,但是为双向迭代器实现这些必要的优化可能并不真正有效:确定序列是否很小的成本(不需要获取序列的大小,但可以停止为一旦很明显序列不小)可能会变得很高。如果快速排序被阻碍运行速度仅慢 20%,则最好使用不同的排序算法:例如,使用合并排序大致在该范围内,并且可以具有稳定的优势。

顺便说一句,传说中选择中位数作为支点似乎没有任何有趣的影响:使用中间值而不是中位数似乎大致一样好(但它确实是比任何一端更好的选择)。

【讨论】:

  • 我认为,pivot 选择对随机数据影响不大,但对大部分排序的数据影响很大,并且应用程序获得大部分排序的数据并不少见。
  • @rici:不。支点的选择并不重要。您需要以不同的方式防范恶意订单(使用介绍排序),在这种情况下,选择一个支点是不必要的复杂性和/或浪费时间。
  • 啊,我看错你了。是的,中间值 3 和中间值之间几乎没有区别,但中间值 3 的成本可以忽略不计,因为所需的比较取代了分区的比较,这是 Wirth iirc 提出的观点。无论如何,选择中间也需要随机访问。
猜你喜欢
  • 2019-05-12
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2020-08-14
  • 1970-01-01
  • 2015-07-16
相关资源
最近更新 更多