使用 STL 迭代器实现 Bentley-McIlroy 三向分区?

Posted

技术标签:

【中文标题】使用 STL 迭代器实现 Bentley-McIlroy 三向分区?【英文标题】:Implementing Bentley-McIlroy three-way partitioning using STL iterators? 【发布时间】:2011-08-31 22:10:09 【问题描述】:

在他们的谈话"Quicksort is Optimal" 中,Sedgewick 和 Bentley 提到了快速排序分区步骤的修改版本,称为 Bentley-McIlroy 三向分区。此版本的分区步骤通过始终从剩余部分中提取枢轴元素的副本来优雅地适应包含相等键的输入,确保在包含重复项的数组上调用该算法时仍然表现良好。

此处转载此分区步骤的C代码:

void threeWayPartition(Item a[], int l, int r)
 
  int i = l-1, j = r, p = l-1, q = r; Item v = a[r];
  if (r <= l) return;
  for (;;)
     
       while (a[++i] < v) ;
       while (v < a[--j]) if (j == l) break;
       if (i >= j) break;
       exch(a[i], a[j]);
       if (a[i] == v)  p++; exch(a[p], a[i]); 
       if (v == a[j])  q--; exch(a[j], a[q]); 
     
  exch(a[i], a[r]); j = i-1; i = i+1;
  for (k = l; k < p; k++, j--) exch(a[k], a[j]);
  for (k = r-1; k > q; k--, i++) exch(a[i], a[k]);

我有兴趣将这个版本的快速排序实现为 STL 算法(只是为了我自己的启发,而不是作为非常快速的 std::sort 的替代品)。为了做到这一点,理想情况下,我会接受定义要排序的范围的 STL 迭代器范围作为算法的输入。因为快速排序不需要随机访问,我希望这些迭代器是双向迭代器,因为这将使算法更通用,并允许我对std::lists 和其他仅支持双向访问的容器进行排序。

但是,这有一个小问题。请注意,三向分区算法的第一行包含以下内容:

int i = l-1, p = l-1;

这最终会创建两个在要分区的范围之前的整数,这很好,因为在循环体中它们在使用之前会递增。但是,如果我用双向迭代器替换这些索引,则此代码不再具有定义的行为,因为它在要排序的范围开始之前备份了一个迭代器。

我的问题如下 - 基本上不重写算法的核心,有没有办法调整此代码以使用 STL 样式的迭代器,因为该算法首先在以下位置备份迭代器范围的开始?现在,我唯一的想法是引入额外的变量来“假装”我们在第一步备份了迭代器,或者用特殊的迭代器适配器装饰迭代器,允许您在开始之前通过跟踪来备份在范围开始之前您有多少逻辑步骤。这些看起来都不是很优雅......我错过了什么吗?有直接的解决方案吗?

谢谢!

【问题讨论】:

您没有定义 k,即使我定义了,它似乎也不起作用。 codepad.org/wlLQ64ei 对不起...此代码是复制并粘贴了原始幻灯片中的琐碎修改。我会调查的。 请注意,这只是分区步骤,而不是完整的快速排序。您需要在适当的子数组上进行递归以完成算法。 没关系,你刚刚发布了分区步骤。 顺便说一句——如果你没有随机访问迭代器,你就不能真正使用“更好的”枢轴选择(伪中位数、随机化等)。真的值得吗,只是为了能够对std::list进行排序? 【参考方案1】:

without substantially rewriting the core of the algorithm

这几乎限制了您尝试破解边界问题,因此您需要使用自定义迭代器适配器,或将迭代器包装在 boost::optional 或类似的东西中,以便您知道它何时是第一个访问。

更好的办法是修改算法以适应手头的工具(这正是 STL 所做的,对不同的迭代器类型使用不同的算法)。

我不知道this is correct,但它以不同的方式描述算法,不需要迭代器越界。


编辑:话虽如此,我已经开始尝试了。此代码未经测试,因为我不知道在给定输入的情况下输出应该是什么样子 - 有关详细信息,请参阅 cmets。它只会有机会为双向/随机访问迭代器工作。
#include <algorithm>
#include <iterator>

template <class Iterator>
void three_way_partition(Iterator begin, Iterator end)

    if (begin != end)
    
        typename Iterator::value_type v = *(end - 1);

        // I can initialise it to begin here as its first use in the loop has
        // changed to post-increment (its pre-increment in your original
        // algorithm).
        Iterator i = begin;

        Iterator j = end - 1;

        // This should be begin - 1, but thats not valid, I set it to end
        // to act as a sentinal value, that way I know when im incrementing
        // p for the first time, and can set it to begin.
        Iterator p = end;

        Iterator q = end - 1;

        for (;;)
        
            while (*(i++) < v);

            while (v < *(--j))
            
                if (j == begin)
                
                    break;
                
            

            if (std::distance(i, j) <= 0)
            
                break;
            

            if (*i == v)
            
                if (p == end)
                
                    p = begin;
                
                else
                
                    ++p;
                

                std::iter_swap(p, i);
            

            if (v == *j)
            
                --q;
                std::iter_swap(j, q);
            
        

        std::iter_swap(i, end - 1);

        j = i - 1;
        i++;

        for (Iterator k = begin; k < p; ++k, --j)
        
            std::iter_swap(k, j);
        

        for (Iterator k = end - 2; k > q; --k, ++i)
        
            std::iter_swap(i, k);
        
    

【讨论】:

【参考方案2】:

当前排名靠前的答案的一个主要问题是,对std::distance 的调用在最坏的情况下使迭代成为二次方。没有唯一键的序列会导致更糟糕的情况行为,这尤其令人遗憾,因为这正是 3 路分区旨在加速的情况。

这以最佳方式实现了 Bentley-McIlroy 3 路分区,以用于双向迭代器,

template <typename Bi1, typename Bi2>
  Bi2 swap_ranges_backward(Bi1 first1, Bi1 last1, Bi2 last2)
  
        typedef typename std::reverse_iterator<Bi1> ri1;
        typedef typename std::reverse_iterator<Bi2> ri2;
        return std::swap_ranges(ri1(last1), ri1(first1), ri2(last2)).base();
  

template <typename Bi, typename Cmp>
  std::pair<Bi, Bi>
  partition3(Bi first, Bi last,
    typename std::iterator_traits<Bi>::value_type pivot, Cmp comp)
  
        Bi l_head = first;
        Bi l_tail = first;

        Bi r_head = last;
        Bi r_tail = last;

        while ( true )
         
           // guarded to avoid overruns.
           //
           // @note this is necessary since ordered comparisons are
           // unavailable for bi-directional iterator types.
           while ( true )
              if (l_tail == r_head)
                 goto fixup_final;
              else if (comp(*l_tail, pivot))
                 ++l_tail;
              else
                 break;
           --r_head;
           while ( true )
              if (l_tail == r_head)
                 goto fixup_right;
              else if (comp(pivot, *r_head))
                 --r_head;
              else
                 break;

           std::iter_swap(l_tail, r_head);

           // compact equal to sequence front/back.
           if (!comp(*l_tail, pivot))
              std::iter_swap(l_tail, l_head++);
           if (!comp(pivot, *r_head))
              std::iter_swap(r_head, --r_tail);
           ++l_tail;
         

fixup_right:
        // loop exited before chance to eval.
        if (!comp(pivot, *r_head))
           ++r_head;
fixup_final:
        // swap equal to partition point.
        if ((l_tail - l_head) <= (l_head - first))
           l_tail = std::swap_ranges(l_head, l_tail, first);
        else
           l_tail = swap_ranges_backward(first, l_head, l_tail);

        if ((r_tail - r_head) <= (last - r_tail))
           r_head = swap_ranges_backward(r_head, r_tail, last);
        else
           r_head = std::swap_ranges(r_tail, last, r_head);
        // equal range in values equal to pivot.
        return std::pair<Bi, Bi>(l_tail, r_head);
  

注意:这已使用 Bentley 验证套件进行了测试。受保护的提前的一个很好的副作用是,这个函数对于一般用途是安全的(对pivot 或序列长度没有限制)。

示例用法,

template<typename Bi, typename Cmp>
  void qsort_bi(Bi first, Bi last, Cmp comp)
  
        auto nmemb = std::distance(first, last);
        if (nmemb <= 1)
           return;
        Bi pivot = first;
        std::advance(pivot, std::rand() % nmemb);

        std::pair<Bi, Bi> equal = partition3(first, last, *pivot, comp);
        qsort_bi(first, equal.first, comp);
        qsort_bi(equal.second, last, comp);
  

template<typename Bi>
  void qsort_bi(Bi first, Bi last)
  
        typedef typename std::iterator_traits<Bi>::value_type value_type;
        qsort_bi(first, last, std::less<value_type>());
  

虽然上述排序可能有效,但它说明了另一个答案已经提出的观点,即双向迭代器和快速排序不适合。

如果无法在恒定时间内选择合适的支点,性能损失会使快速排序成为劣等选择。此外,双向迭代器对于链表上的最佳排序来说过于通用,因为它们无法利用列表的优势,例如恒定时间插入和拼接。最后,另一个更微妙(可能有争议)的问题是,用户希望链表上的排序是稳定的。

我的建议? sgi STL 使用的自下而上的迭代归并排序。它经过验证、稳定、简单和快速(保证 n*log(n))。不幸的是,这个算法似乎没有唯一的名称,而且我无法单独找到一个实现的链接,所以在这里重复。

这是一个非常巧妙的算法,它的工作方式类似于二进制计数器(非空列表等于 1)。 Counter 保存大小为 2^index 的列表(即 1,2,4,8 ...)。随着每个元素(位)的添加,可能会启动一个进位,该进位将级联成更高阶的列表(二进制加法)。

template <typename Tp>
  void msort_list(std::list<Tp>& in)
  
        std::list<Tp> carry;
        std::list<Tp> counter[64];
        int fill = 1;

        while (!in.empty()) 
            carry.splice(carry.begin(), in, in.begin());
            int i = 0;
            for (; !counter[i].empty(); i++) 
                // merge upwards for stability.
                counter[i].merge(carry);
                counter[i].swap(carry);
            
            counter[i].swap(carry);
            if (i == fill) ++fill;
        
        for (int i = 1; i < fill; i++)
            counter[i].merge(counter[i-1]);
        in.swap(counter[fill-1]);
  

注意:此版本在几个方面与原版有所不同。 1) 我们从 1 而不是 0 开始 fill,这允许我们跳过大小检查并在不影响行为的情况下进行最终交换。 2) 原来的内部循环条件添加了i &lt; fill,这个检查是无关的(可能是计数器数组是动态的版本的保留)。

【讨论】:

【参考方案3】:

不幸的是,表达式“k

if (i >= j) break; 

必须离开并被替换为

if (i == j) break;

这意味着您需要在“内部”循环中添加额外的条件,以确保 j(特别是)不会减少太多。在使该算法针对双向迭代器运行时,无法满足“无需大量重写”的网络/网络约束。

【讨论】:

【参考方案4】:

考虑到该函数所做的所有交换,仅执行以下操作不是更容易(而且可能更有效)吗?

template <typename For, typename Cmp>
  std::pair<For, For>
  partition_3way(For first, For last,
          typename std::iterator_traits<For>::value_type pivot, Cmp comp)
  
        For lower = std::partition(first, last, std::bind2nd(comp, pivot));
        For upper = std::partition(lower, last,
                std::not1(std::bind1st(comp, pivot)));
        // return equal range for elements equal to pivot.
        return std::pair<For, For>(lower, upper);
  

【讨论】:

我不确定这是否会减少交换。你能证实这一点吗? 交换次数应该相同。在最坏的情况下,这可能会进行两倍的比较,但是,如果您看到最坏的情况,那是由于枢轴选择不佳,并且您遇到的问题比任一函数的线性复杂度都要大。 STL 非常高效,我敢打赌,在实践中这将至少与原始算法竞争。 创建partition_3way的思路是不使用2个分区。因为它效率低。 我整晚都在考虑并使用双向迭代器,您的解决方案是一个糟糕的解决方案,因为它是 O(N+K),其中 N = 距离(第一个,最后一个),K = 距离(较低,最后的)。当代码可能只是 O(N) 时。但如果您使用Foward Iter 您的解决方案,那就是解决方案。修改你的答案,说它只是为了FIter,我投票赞成。

以上是关于使用 STL 迭代器实现 Bentley-McIlroy 三向分区?的主要内容,如果未能解决你的问题,请参考以下文章

[ C++ ] STL_vector -- 迭代器失效问题

C++STL之list的使用和模拟实现

C++ STL应用与实现17: 如何使用迭代器辅助函数

STL迭代器相关的输出迭代器

使用 STL 迭代器实现 Bentley-McIlroy 三向分区?

STL源码剖析(迭代器)