在双向迭代器上实现快速排序

时间:2016-02-03 07:16:28

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

使用具有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);
  }
}

2 个答案:

答案 0 :(得分:7)

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

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

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

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

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

实际分类的其他方面可能需要随机访问迭代器;那些只是我的头脑。

答案 1 :(得分:0)

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

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

快速quicksort的基本步骤如下:

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

    • 使用过的分区需要利用标记来减少比较次数。
    • 观察分区是否有任何工作可以通过赌博运行插件来导致提前纾困,这会在做太多工作时停止。
    • 为了增加使用中点而不是序列的任何一端进行排序的前一点的机会,因为pivot具有优势(这也需要随机访问,但这是一个相对较小的原因)。
  4. 我还没有完成实验,但是对双向迭代器实现这些必要的优化可能并不真正有效:确定序列是否很小的成本(不需要获得序列的大小)但是一旦明确序列不小就可以停止)可能变得很高。如果快速排序阻碍运行速度降低约20%,则最好使用不同的排序算法:例如,使用mergesort大致在该范围内,并且具有可以稳定的优势。

    BTW,中位数作为支点的传说中选择似乎没有任何有趣的影响:使用中间值而不是中位数似乎大致同样好(但它确实是一个更好的选择无论结束)。