快速排序的奇怪时间复杂度,c++

12

我一直在测试不同排序算法对于不同数列的时间复杂度,一切都进行得很顺利,直到我得到了快速排序(以中间值为基准)对于一个前一半是升序,后一半是降序的数列的结果,图形如下:

enter image description here

(在“V”代表的序列中,前一半是降序,后一半是升序,“A”代表的序列中,前一半是升序,后一半是降序。)

其他种类的数列的结果看起来和我预期的一样,但也许我的算法有问题吗?

void quicksort(int l,int p,int *tab)
{
int i=l,j=p,x=tab[(l+p)/2],w; //x - pivot
do 
{
    while (tab[i]<x)
    {
        i++;
    }
    while (x<tab[j])
    {
        j--;
    }
    if (i<=j)
    {
        w=tab[i];
        tab[i]=tab[j];
        tab[j]=w;
        i++;
        j--;
    }
}
while (i<=j);
if (l<j)
{
    quicksort(l,j,tab);
}
if (i<p)
{
    quicksort(i,p,tab);
}
}

有人知道是什么原因导致了这样奇怪的结果吗?


3
快速排序使用中间值作为枢轴存在已知缺陷。经典算法。 - SergeyA
@SergeyA 我在互联网上搜索了类似的图表,但没有找到。有没有什么地方可以让我了解为什么会在复杂度之间跳跃的原因? - vois
你用什么指标来创建这样史诗般的图表?你在测量时间吗?怎么测量? - tomascapek
@RainbowTom clock_t start = clock(); quicksort(0,n-1,tab); clock_t finish = clock(); sfile<<double(finish-start)/CLOCKS_PER_SEC<<" "; - vois
@vois,我的答案有什么问题吗? :) - blazs
4个回答

10
TL;DR: 问题在于选择基准点的策略,使得在这些类型的输入(A型和V型序列)上反复做出错误的选择。这导致快速排序产生高度“不平衡”的递归调用,从而导致算法表现非常糟糕(对于A型序列是二次时间)。
恭喜你,你已经(重新)发现了一种对于选择中间元素作为基准点的快速排序版本而言具有敌对性的输入(或者说是一类输入)。
参考资料:A型序列的一个示例是1 2 3 4 3 2 1,即一个增加、达到中间点后又减少的序列;V型序列的一个示例是4 3 2 1 2 3 4,即一个减少、达到中间最小值后又增加的序列。
请看当你选择中间元素作为A型或V型序列的枢轴时会发生什么。在第一种情况下,当你传递算法A型序列1 2 ... n-1 n n-1 ... 2 1时,枢轴是数组的最大元素——这是因为A型序列的最大元素是中间元素,而你选择中间元素作为枢轴——并且你将对大小为0(实际上你的代码不会对0个元素进行调用)和n-1的子数组进行递归调用。在对大小为n-1的子数组进行的下一次调用中,你将选择子数组的最大元素(也就是原始数组的第二大元素)作为枢轴;以此类推。这导致了性能较差,因为运行时间为O(n)+O(n-1)+...+O(1) = O(n^2),因为在每一步中,你基本上传递了几乎整个数组(除了枢轴),换句话说,递归调用中数组的大小高度不平衡。

以下是A型序列1 2 3 4 5 4 3 2 1的跟踪:

blazs@blazs:/tmp$ ./test 
pivot=5
   1   2   3   4   1   4   3   2   5
pivot=4
   1   2   3   2   1   3   4   4
pivot=3
   1   2   3   2   1   3
pivot=3
   1   2   1   2   3
pivot=2
   1   2   1   2
pivot=2
   1   1   2
pivot=1
   1   1
pivot=4
   4   4
   1   1   2   2   3   3   4   4   5

你可以从跟踪中看到,在递归调用时,该算法选择一个最大的元素(最多可能有两个最大的元素,因此使用不定冠词a而不是定冠词the)作为枢轴。这意味着A形序列的运行时间确实为O(n)+O(n-1)+...+O(1) = O(n^2)。(在技术术语中,A形序列是迫使算法表现不佳的对抗性输入的示例。)
这意味着,如果您绘制“完美”的A形序列的运行时间,其形式为
1 2 3 ... n-1 n n-1 ... 3 2 1

当增加n时,你会看到一个漂亮的二次函数。这是我刚刚为A形序列1 2 ... n-1 n n-1 ... 2 1计算的n=5,105, 205, 305,...,9905的图表:

Running times for A-shaped sequences

在第二种情况下,当您将V形序列传递给算法时,您选择数组中最小的元素作为枢轴,并因此对大小为n-10的子数组进行递归调用(实际上,您的代码不会对0个元素进行调用)。在对大小为n-1的子数组的下一次调用中,您将选择最大元素作为枢轴;以此类推。(但您并不总是做出这样糟糕的选择;很难对这种情况做更多解释。)由于类似原因,这会导致性能差。这种情况略微复杂(它取决于您如何执行“移动”步骤)。
以下是V形序列n n-1 ... 2 1 2 ... n-1 n(其中n=5,105,205,...,49905)的运行时间图表。运行时间有些不规则——正如我所说,这更加复杂,因为您并不总是选择最小的元素作为枢轴。图表:

Running times for V-shaped sequences for increasing sizes.

我用来测量时间的代码:

double seconds(size_t n) {
    int *tab = (int *)malloc(sizeof(int) * (2*n - 1));
    size_t i;

    // construct A-shaped sequence 1 2 3 ... n-1 n n-1 ... 3 2 1
    for (i = 0; i < n-1; i++) {
        tab[i] = tab[2*n-i-2] = i+1;
        // To generate V-shaped sequence, use tab[i]=tab[2*n-i-2]=n-i+1;
    }
    tab[n-1] = n;
    // For V-shaped sequence use tab[n-1] = 1;

    clock_t start = clock();
    quicksort(0, 2*n-2, tab);
    clock_t finish = clock();

    free(tab);

    return (double) (finish - start) / CLOCKS_PER_SEC;
}

我将您的代码进行了修改,以便打印算法的“跟踪”,这样您就可以自己操作并深入了解正在发生的事情:
#include <stdio.h>

void print(int *a, size_t l, size_t r);
void quicksort(int l,int p,int *tab);

int main() {
    int tab[] = {1,2,3,4,5,4,3,2,1};
    size_t sz = sizeof(tab) / sizeof(int);

    quicksort(0, sz-1, tab);
    print(tab, 0, sz-1);

    return 0;
}


void print(int *a, size_t l, size_t r) {
    size_t i;
    for (i = l; i <= r; ++i) {
        printf("%4d", a[i]);
    }
    printf("\n");
}

void quicksort(int l,int p,int *tab)
{
int i=l,j=p,x=tab[(l+p)/2],w; //x - pivot
printf("pivot=%d\n", x);
do 
{
    while (tab[i]<x)
    {
        i++;
    }
    while (x<tab[j])
    {
        j--;
    }
    if (i<=j)
    {
        w=tab[i];
        tab[i]=tab[j];
        tab[j]=w;
        i++;
        j--;
    }
}
while (i<=j);

print(tab, l, p);
if (l<j)
{
    quicksort(l,j,tab);
}
if (i<p)
{
    quicksort(i,p,tab);
}
}

顺便提一下,如果你对每个输入序列进行100次运行时间的平均值,图表显示的运行时间会更加平滑。

我们看到这里的问题是选择主元的策略。让我指出,您可以通过随机化主元选择步骤来缓解对抗性输入的问题。最简单的方法是均匀随机选择主元(每个元素被选为主元的可能性相等); 然后您可以证明该算法在O(n log n)时间内运行很高的概率。(但请注意,要展示此尖峰边界,您需要对输入做出一些假设;如果数字都不同,则该结果肯定成立;例如,请参阅Motwani和Raghavan的随机算法书。)

为了证实我的说法,这里是同一序列使用随机选择主元的运行时间图表,其中 x = tab[l + (rand() % (p-l))]; (确保在主函数中调用 srand(time(NULL)))。 对于A形序列: enter image description here 对于V形序列:

enter image description here


1
在快速排序中,影响运行时间的主要因素之一是使输入随机化。通常选择特定位置的枢轴可能不是最好的选择,除非确定输入已随机洗牌。使用“三数取中法”是广泛使用的方法之一,只是为了确保枢轴是一个随机数。从你的代码来看,你没有实现它。
此外,当递归快速排序遇到一些开销时,因为它使用内部堆栈(将必须生成多个函数并分配参数),所以建议在剩余数据的大小约为10-20时使用其他排序算法,如“插入排序”,因为这会使其快约20%。
void quicksort(int l,int p,int *tab){
  if ( tab.size <= 10 ){

      IntersionSort(tab);
   }
 ..
 ..}

这是类似的内容。

一般情况下,快速排序的最佳运行时间为nlogn,最坏情况下的运行时间为n^2,通常是由于非随机输入或重复输入引起的。


一个非随机输入会导致复杂度在nlogn和n^2之间跳跃吗?如果可以,为什么? - vois
是的,非随机输入可能会导致它从nlogn跳到n^2,但这取决于具体实现。为了保险起见,一些人会先对输入进行洗牌。如果输入不是随机的,选择中间数并没有太大的区别,因为结果序列会倾向于一侧。我在这里无法给出更多解释,但您可以查看https://en.wikipedia.org/wiki/Quicksort的“选择枢轴”部分。 - Ibukun Muyide

1
所有答案都有很好的观点。我的想法是,算法本身没有问题(因为枢轴问题是众所周知的,并且是O(n ^ 2)的原因之一),但您测量时间的方式可能存在问题。 clock() - 返回从某个时间点开始经过的处理器时钟数(可能是程序启动时?不重要)。
您测量时间的方法依赖于时钟节拍的恒定长度,我认为这并不能得到保证。
关键是,今天许多(全部?)现代处理器会动态更改其频率以节省能源。我认为这是非常不确定的,因此每次启动程序时,CPU频率不仅取决于输入的大小,还取决于系统中正在发生的情况。我理解的方式是,在程序执行期间,一个时钟节拍的长度可能会非常不同。
我试图查找宏CLOCKS_PER_SEC实际上是做什么。它是当前每秒钟的时钟数吗?它是否在某些神秘的时间段内进行了一些平均值?遗憾的是,我无法弄清楚。因此,我认为您测量时间的方式可能是完全错误的。
由于我的论点基于我不确定的事情,我可能是完全错误的

有一种方法是使用相同的数据多次运行多个测试,使用不同的整体系统使用情况,看看它每次的表现是否明显不同。另一种方法是将计算机的 CPU 频率设置为某个静态值,并以类似的方式进行测试。

想法 是否更好地用“ticks”来测量“时间”?

编辑 1 感谢 @BeyelerStudios,现在我们确定,在 Windows 计算机上不应该依赖 clock(),因为它不遵循 C98 标准。来源

希望我能帮到您,如果我错了,请纠正我 - 我只是一名学生,不是硬件专家。


1
在微软的库中,CLOCKS_PER_SEC是一个常量值(至少在VS2013中是这样,不知道MSVC 14.0是否也是如此)。 - BeyelerStudios
规范化这个可能会很困难 - 如何规范化得好?clock()CLOCK_PER_SEC之间没有(可见的)联系,这可能意味着代码内部某处发生了一些魔法,这不是很好的设计,因此我认为它不是这样工作的...想象一下:要测量时间,您必须调用clock()两次。后台如何知道从哪个点开始规范化?每个偶数次调用吗?那将是极其糟糕的设计... - tomascapek
如果我们阅读msdn文章,那么有两件事值得注意:MS的clock不是标准的(不遵循C99),并且特别提到您不应该依赖它进行时间计算[1]...所以,不要用clock做任何事情... [1]关键字:大约1/CLOCKS_PER_SEC在大约59小时后中断 - BeyelerStudios
那就是这个的结尾了。 - tomascapek
欢迎投票,因为这可能是@vois问题的真正原因。 - tomascapek
显示剩余7条评论

0

是的,但是对于相同类型的序列,算法是否应该在复杂度之间跳跃? - vois
@vois 我认为不应该。请看我的回答。 - tomascapek

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接