【问题标题】:Has anyone seen this improvement to quicksort before?有没有人见过这种对快速排序的改进?
【发布时间】:2011-01-07 12:34:27
【问题描述】:

处理之前快速排序中的重复元素

我找到了一种在快速排序中更有效地处理重复元素的方法,我想知道是否有人以前见过这种方法。

这种方法大大减少了检查重复元素所涉及的开销,从而提高了有无重复元素的性能。通常,重复的元素以几种不同的方式处理,我将首先列举这些方式。

首先,有一种荷兰国旗方法,可以像[ < pivot | == pivot | unsorted | > pivot] 那样对数组进行排序。

其次,有一种方法,在排序过程中将相等的元素放在最左边,然后将它们移动到中心排序为[ == pivot | < pivot | unsorted | > pivot],然后在排序后将==元素移动到中心。

第三,Bentley-McIlroy 分区将== 元素放在两侧,因此排序为[ == pivot | < pivot | unsorted | > pivot | == pivot],然后== 元素移到中间。

最后两种方法是为了减少开销。

我的方法

现在,让我解释一下我的方法如何通过减少比较次数来改进快速排序。 我一起使用两个快速排序功能,而不仅仅是一个。

我将调用q1 的第一个函数,它将数组排序为[ < pivot | unsorted | >= pivot]

我将调用q2 的第二个函数,它将数组排序为[ <= pivot | unsorted | > pivot]

现在让我们一起看看它们的用法,以改进对重复元素的处理。

首先,我们调用q1 对整个数组进行排序。它选择一个我们将进一步称为pivot1 的枢轴,然后围绕pivot1 进行排序。因此,我们的数组被排序为[ < pivot1 | >= pivot1 ]

然后,对于[ < pivot1]分区,我们再次发送到q1,这部分是相当正常的,所以我们先对另一个分区进行排序。

对于[ >= pivot1] 分区,我们将其发送到q2q2 选择一个枢轴,我们将在此子数组中将其称为pivot2,并将其排序为[ <= pivot2 | > pivot2]

如果我们现在查看整个数组,我们的排序看起来像[ < pivot1 | >= pivot1 and <= pivot2 | > pivot2]。这看起来很像双轴快速排序。

现在,让我们回到q2 ([ <= pivot2 | > pivot2]) 内部的子数组。

对于[ > pivot2] 分区,我们只是将其发送回q1,这不是很有趣。

对于[ <= pivot2]分区,我们首先检查是否pivot1 == pivot2。如果它们相等,则该分区已经排序,因为它们都是相等的元素!如果枢轴不相等,那么我们只需将此分区再次发送到q2,它会选择一个枢轴(进一步pivot3),排序,如果pivot3 == pivot1,那么它不必对[ <= pivot 3]和很快。

希望你现在明白了。这种技术的改进在于处理相等的元素,而不必检查每个元素是否也等于枢轴。换句话说,它使用的比较更少。

与其总子数组的大小,然后在这种情况下对重复元素进行更标准的检查(上面列出的方法之一)。

源代码

这里有两个非常简化的qs1qs2 函数。他们使用 Sedgewick 聚合指针排序方法。它们显然可以非常优化(例如,它们选择枢轴非常差),但这只是为了展示这个想法。我自己的实现更长、更快、更难阅读,所以让我们从这个开始:

// qs sorts into [ < p | >= p ]
void qs1(int a[], long left, long right){
    // Pick a pivot and set up some indicies
    int pivot = a[right], temp;
    long i = left - 1, j = right;
    // do the sort
    for(;;){
        while(a[++i] < pivot);
        while(a[--j] >= pivot) if(i == j) break;
        if(i >= j) break;
        temp = a[i];
        a[i] = a[j];
        a[j] = temp;
    }
    // Put the pivot in the correct spot
    temp = a[i];
    a[i] = a[right];
    a[right] = temp;

    // send the [ < p ] partition to qs1
    if(left < i - 1)
        qs1(a, left, i - 1);
    // send the [ >= p] partition to qs2
    if( right > i + 1)
        qs2(a, i + 1, right);
}

void qs2(int a[], long left, long right){
    // Pick a pivot and set up some indicies
    int pivot = a[left], temp;
    long i = left, j = right + 1;
    // do the sort
    for(;;){
        while(a[--j] > pivot);
        while(a[++i] <= pivot) if(i == j) break;
        if(i >= j) break;
        temp = a[i];
        a[i] = a[j];
        a[j] = temp;
    }
    // Put the pivot in the correct spot
    temp = a[j];
    a[j] = a[left];
    a[left] = temp;

    // Send the [ > p ] partition to qs1
    if( right > j + 1)
        qs1(a, j + 1, right);
    // Here is where we check the pivots.
    // a[left-1] is the other pivot we need to compare with.
    // This handles the repeated elements.
    if(pivot != a[left-1])
            // since the pivots don't match, we pass [ <= p ] on to qs2
        if(left < j - 1)
            qs2(a, left, j - 1);
}

我知道这是一个相当简单的想法,但是当我添加标准快速排序改进(3 中值枢轴选择和小数组的插入排序开始)时,它在运行时提供了相当显着的改进。如果您要使用此代码进行测试,请仅对随机数据执行此操作,因为枢轴选择不佳(或改进枢轴选择)。要使用这种类型,您可以调用:

qs1(array,0,indexofendofarray);

一些基准

如果您想知道它的速度有多快,这里有一些数据供初学者参考。这使用了我的优化版本,而不是上面给出的版本。但是,上面给出的时间仍然比std::sort 时间更接近双轴快速排序。

在具有 2,000,000 个元素的高度随机数据上,我得到了这些时间(通过对几个连续的数据集进行排序):

std::sort - 1.609 seconds  
dual-pivot quicksort - 1.25 seconds  
qs1/qs2 - 1.172 seconds

std::sort 是 C++ 标准库排序,双轴快速排序是几个月前由 Vladimir Yaroslavskiy 提出的,qs1/qs2 是我的快速排序实现。

在更少的随机数据上。有 2,000,000 个元素并使用 rand() % 1000 生成(这意味着每个元素大约有 2000 个副本)时间是:

std::sort - 0.468 seconds  
dual-pivot quicksort - 0.438 seconds  
qs1/qs2 - 0.407 seconds

在某些情况下双轴快速排序胜出,我确实意识到双轴快速排序可以进行更多优化,但我的快速排序可以安全地说明这一点。

有人见过这个吗?

我知道这是一个很长的问题/解释,但是你们中的任何人以前见过这种改进吗?如果是这样,那为什么不使用它?

【问题讨论】:

  • 你要做的是n学术文献搜索。 R Samuel Klatchko 在上一个关于这个主题的问题中为您提供了最佳期刊的链接,theMick 告诉您如果您不知道自己在做什么,如何开始。
  • 我的格式现在好点了吗?你有什么建议吗?有没有更好的网站可以发布这个?
  • 无论你做什么,根据 Allen Weiss 书中给出的决策树模型证明(C++ 中的数据结构和算法),排序将在平均情况下进行 O(NlogN) 比较。
  • 是的,这仍然是平均 O(N log N) 排序。该系数仅比处理重复元素的其他系数小。
  • @Raccha:不熟悉那本书,但我认为 O(Nlog N) 下限假定只有 1 个比较函数可用。当然,如果 &lt;=&gt;= 神奇地使渐近更快的算法成为可能,我会感到非常惊讶!

标签: c++ algorithm sorting quicksort


【解决方案1】:

弗拉基米尔·雅罗斯拉夫斯基 | 9 月 11 日 12:35 用新的 Dual-Pivot Quicksort 替换 java.util.Arrays 中的 Quicksort

访问http://permalink.gmane.org/gmane.comp.java.openjdk.core-libs.devel/2628

【讨论】:

【解决方案2】:

要回答您的问题,不,我以前从未见过这种方法。我不会分析您的代码并做其他艰苦的工作,但也许以下是正式展示您的算法的下一步/考虑因素。在现实世界中,排序算法被实现为:

良好的可扩展性/复杂性低开销

扩展和开销是显而易见的,并且易于衡量。在分析排序时,除了时间测量比较和交换的次数。大文件的性能也取决于磁盘寻道时间。例如,归并排序适用于带有磁盘的大文件。 (另见Quick Sort Vs Merge Sort

具有良好性能的广泛输入

有很多数据需要排序。众所周知,应用程序会以模式生成数据,因此重要的是要使排序能够抵御某些模式下的不良性能。您的算法针对重复数字进行了优化。如果所有数字都重复但只重复一次(即 seq 1000>file; seq 1000>>file; shuf 文件)怎么办?如果数字已经排序怎么办?倒序排列?那么 1,2,3,1,2,3,1,2,3,1,2,3 的模式呢? 1,2,3,4,5,6,7,6,5,4,3,2,1? 7,6,5,4,3,2,1,2,3,4,5,6,7?在这些常见场景之一中表现不佳会破坏交易!在与已发布的通用算法进行比较之前,最好先准备好此分析。

病理表现风险低

在输入的所有排列中,有一个比其他的表现更差。这比平均水平差多少?有多少排列会提供类似的较差性能?

祝你接下来的步骤好运!

【讨论】:

  • 是的,尽管排序算法也可能最终出现在“特定于用例的盒子”中,利用特定数据集的属性来获得更好的性能,但代价是总体上更差.因此,在某些输入上具有病态性能的算法可能仍然有价值,只是不像一般算法。
【解决方案3】:

这是一项了不起的改进,如果您期望有很多相等的对象,我相信它已经专门实现了。有很多这样的墙tweeks。

如果我理解你写的所有内容,那么它通常不被“知道”的原因是它确实提高了基本的 O(n2) 性能。这意味着,对象数量增加一倍,时间增加四倍。除非所有对象都相等,否则您的改进不会改变这一点。

【讨论】:

  • 我认为你的意思是“它并没有提高基本的 O(n2) 性能”
  • 我认为你没有抓住重点。要获得不错的快速排序,您需要能够处理重复的元素。当前执行此操作的方法比我的方法增加了更多的快速排序。 O(N^2) 最坏情况下的性能来自重复元素和/或选择错误的枢轴。此改进解决了重复元素部分,并且中位数为 3 的枢轴选择方法或选择随机枢轴可以帮助选择更好的枢轴。
  • 我认为您的意思是“我认为您的意思是“它不会提高基本的 O(n2) 性能””
  • n^2 只是最坏的情况,没有太多实际后果。由于我必须在真机上运行它,其中 c1*O(n^2) = c2*O(n log n),我想知道常量!
  • 是的,我同意,O(n2) 几乎没有实用价值,但这是我关于为什么您没有发现此类改进已发表的理论。好吧,实际上,这可能是因为还有其他排序方法可能更有趣需要改进。我确实喜欢你处理重复元素的方式。
【解决方案4】:

std:sort 并不是很快。

这是我将它与随机并行非递归快速排序进行比较得到的结果:

pnrqSort(多头): .:.1 000 000 36ms(每毫秒的项目数:27777.8)

.:.5 000 000 140ms(每毫秒的项目数:35714.3)

.:.10 000 000 296ms(每毫秒的项目数:33783.8)

.:.50 000 000 1s 484ms(每毫秒的项目数:33692.7)

.:.100 000 000 2s 936ms(每毫秒的项目数:34059.9)

.:.250 000 000 8s 300ms(每毫秒的项目数:30120.5)

.:.400 000 000 12s 611ms(每毫秒的项目数:31718.3)

.:.500 000 000 16s 428ms(每毫秒的项目数:30435.8)

std::sort(longs) .:.1 000 000 134ms(每毫秒的项目数:7462.69)

.:.5 000 000 716ms(每毫秒的项目数:6983.24)

std::long 的排序向量

1 000 000 511ms(每毫秒的项目数:1956.95)

2 500 000 943 毫秒(每毫秒的项目数:2651.11)

由于你有额外的方法,它会导致更多的堆栈使用,最终会减慢速度。为什么使用 3 的中位数,我不知道,因为它是一种糟糕的方法,但是使用随机枢轴点快速排序从来不会对统一或预排序的数据有大问题,并且不存在故意使用 3 杀手数据的中位数的危险。

【讨论】:

  • 是的,我考虑过使用其他枢轴选择方法,包括随机枢轴。那不是重点。另外,请注意您的既是非递归的又是并行的。当然会更快!我使用递归是因为它实现起来更简单,而且人们更容易快速理解。我的方法也可以是非递归的和并行的。是的,std::sort 不是最快的,但它提供了一个通用的比较函数。然而,双轴快速排序在递归和串行方面非常快。
  • 那么到底有什么意义呢?显然没有,无缘无故地否决我的回答。正如我所指出的,使用两个枢轴方法将是堆栈开销的两倍,而且正如我指出的那样,它不会比现有方法获得任何好处,那么有什么意义呢?显然没有,就像问题本身一样。
  • 我对您的回复投了反对票,因为您将苹果与橙子进行比较。将非递归并行快速排序与递归串行快速排序进行比较是没有意义的。您甚至没有指定使用了多少个处理器。使用两种不同的数据透视方法不会使堆栈调用次数增加一倍——它与使用基本快速排序的调用次数相同。如果将堆栈调用的总数添加到每个函数(qs1 和 qs2)和基本快速排序中,您应该得到相同的数字。另外,也许你错过了它可以用来改进非递归和并行方法。
  • 其实在有重复数据的情况下,qs1和qs2加起来的调用次数会少于基本快速排序中的栈调用次数。
【解决方案5】:

似乎没有人喜欢你的算法,但我喜欢。 在我看来,这是一种以某种方式重新进行经典快速排序的好方法 与高度重复的元素一起使用是安全的。 你的 q1 和 q2 子算法,在我看来实际上是相同的算法 除了 软件—实践和经验 23,11(1993 年 11 月)1249-1265 电子版在这里 http://www.skidmore.edu/~meckmann/2009Spring/cs206/papers/spe862jb.pdf 查看他们进行快速排序的测试。你的想法可能会更好和/或更好, 但它需要运行他们尝试过的各种测试,使用一些 特定的枢轴选择方法。找到一个通过所有测试而不会遭受二次运行时的问题。那么如果你的算法比他们的算法更快更好,那么你显然会做出有价值的贡献。

在我看来,他们用来生成枢轴的“Tukey Ninther”东西你也可以使用 并且会自动使二次时间最坏情况在实践中很难出现。 我的意思是,如果您只使用 3 的中值并尝试将数组的中间和两个末端元素作为 你的三个,然后一个对手将使初始数组状态增加然后减少,然后你会在一个不太难以置信的输入上以二次运行时跌倒在你的脸上。但是 Tukey Ninther 有 9 个元素,我很难构建 一个似是而非的输入,它会在二次运行时伤害你。

另一种观点和建议: 想想 q1 分割你的数组,然后 q2 分割右子数组的组合, 作为一个 q12 算法产生数组的 3 路拆分。现在,你需要递归 在 3 个子阵列上(如果两个枢轴恰好相等,则只有 2 个)。现在总是 递归你要递归的子数组中的最小的,FIRST,和 最大的 LAST ——并且不要将这个最大的实现为递归,而只是停留在同一个例程中并使用缩小的窗口循环回到顶部。那样 您在 q12 中的递归调用比您的要少 1 次,但重点是, 现在递归堆栈不可能超过 O(logN) 长。 好的?这解决了另一个烦人的最坏情况问题,快速排序可能会遇到,同时也使 无论如何,您的代码要快一些。

【讨论】:

    猜你喜欢
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2015-03-13
    • 1970-01-01
    • 1970-01-01
    • 1970-01-01
    • 2017-11-03
    相关资源
    最近更新 更多