Java 为什么JMH报告了一个简单的快速排序的奇怪时间——显然与N*log(N)不成比例?

Java 为什么JMH报告了一个简单的快速排序的奇怪时间——显然与N*log(N)不成比例?,java,time-complexity,quicksort,jmh,Java,Time Complexity,Quicksort,Jmh,为了研究一种排序算法(我自己的),我决定将其性能与经典的快速排序进行比较,令我大吃一惊的是,我发现我实现快速排序所花费的时间与N log(N)远不成正比。我彻底地试图在我的快速排序中找到一个错误,但没有成功。它是排序算法的一个简单版本,使用不同大小的整数数组,填充随机数,我不知道错误会潜入何处。我甚至计算了代码执行的所有比较和交换,它们的数量与nlog(N)相当成比例。我完全糊涂了,无法理解我观察到的现实。以下是对1000、2000、4000、8000和16000个随机值(用JMH测量)的排序数

为了研究一种排序算法(我自己的),我决定将其性能与经典的
快速排序
进行比较,令我大吃一惊的是,我发现我实现
快速排序
所花费的时间与
N log(N)
远不成正比。我彻底地试图在我的
快速排序中找到一个错误,但没有成功。它是排序算法的一个简单版本,使用不同大小的
整数
数组,填充随机数,我不知道错误会潜入何处。我甚至计算了代码执行的所有比较和交换,它们的数量与
nlog(N)
相当成比例。我完全糊涂了,无法理解我观察到的现实。以下是对1000、2000、4000、8000和16000个随机值(用
JMH
测量)的排序数组的Benchmark结果:

显然,我观察到的时间复杂性远远不是O(n log(n))
,它几乎是O(n^2)。可能有一点怀疑,随机种子是如此不幸,以至于数组中的值恰好接近最坏情况。这一概率非常接近于0,但不是0。但是用几个不同的随机种子得到的结果与此非常相似

下面是比较和交换的数量(对于每个大小随机填充数组的40次迭代):

正如大家所看到的,操作的数量非常符合
O(Nlog(N))
定律

甚至有可能怀疑单个操作的成本取决于我们处理的数组的大小,我已经检查了它是否正确(使用一个简单的方法反转不同大小的数组)——不,正如预期的那样,不是这样。时间非常接近
O(n)

我唯一能想到的是我的代码中有一些棘手的逻辑错误,但我无法理解

有人能帮我吗

代码如下:

import java.io.IOException;
import java.util.Locale;
import java.util.Random;
import java.util.function.Consumer;

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.RunnerException;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;

/**
 * Why does quicksort take time disproportionate to N * log(N)?
 * Rectified for StackOverflow
 * 21.05.17 16:20:01
 */

@State(value = Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(java.util.concurrent.TimeUnit.MICROSECONDS)
@Fork(value = 2)
@Warmup(iterations = 5, time = 10)
@Measurement(iterations = 5, time = 10)
public class QSortBenchmarks {

  private static final int LOOPS_TO_ITERATE = 40; // 40;

  private static final int RAND_SEED = 123456789;
  private static final int RAND_LIMIT = 10000;
  private static final Random RAND = new Random(RAND_SEED); // Constant seed for reproducibility;

  private static int cmpCount = 0, swapCount = 0;
  private static int cmpTotal = 0, swapTotal = 0;

  private static final Integer[] array01000 = new Integer[1000];
  private static final Integer[] array02000 = new Integer[2000];
  private static final Integer[] array04000 = new Integer[4000];
  private static final Integer[] array08000 = new Integer[8000];
  private static final Integer[] array16000 = new Integer[16000];

  @Setup
  public static void initData() {
    cmpCount = 0; swapCount = 0;

    fillWithRandoms(array01000);
    fillWithRandoms(array02000);
    fillWithRandoms(array04000);
    fillWithRandoms(array08000);
    fillWithRandoms(array16000);
  }

  public static void main(String[] args) throws IOException, RunnerException {
    Locale.setDefault(Locale.US);
    initData();

    runJMH(); // Run benchmarks. Comment-out it, if you want just to count comparisons etc.
//    System.exit(0); // If don't want to count comparisons and swaps

    System.out.printf("\nRand seed = %d, rand limit = %d, iterations = %d\n",
                      RAND_SEED, RAND_LIMIT, LOOPS_TO_ITERATE);

    System.out.print("sortArray01000(): ");
    loopOverMethod(qq -> sortArray01000());

    System.out.print("sortArray02000(): ");
    loopOverMethod(qq -> sortArray02000());

    System.out.print("sortArray04000(): ");
    loopOverMethod(qq -> sortArray04000());

    System.out.print("sortArray08000(): ");
    loopOverMethod(qq -> sortArray08000());

    System.out.print("sortArray16000(): ");
    loopOverMethod(qq -> sortArray16000());
  }

  private static void loopOverMethod(Consumer<Object> method) {
    cmpTotal = 0; swapTotal = 0;
    for (int loops = 0; loops < LOOPS_TO_ITERATE; loops++ ) {
      initData();
      method.accept(null);;
      cmpTotal += cmpCount; swapTotal += swapCount;
    }
    System.out.printf("avrg compares: %12.3f,  swaps: %12.3f\n",
                      (double)cmpTotal / LOOPS_TO_ITERATE, (double)swapTotal / LOOPS_TO_ITERATE);
  }

  /**
   * @throws RunnerException
   */
  private static void runJMH() throws RunnerException {
    final Options opt = new OptionsBuilder()
        .include(QSortBenchmarks.class.getSimpleName())
        .forks(1)
        .build();
    new Runner(opt).run();
  }

  private static void fillWithRandoms(Integer[] array) {
    for (int i = 0; i < array.length; i++) { // // Fill it with LIST_SIZE random values
      array[i] = (int)(RAND.nextDouble() * RAND_LIMIT);
    }
  }

  @Benchmark
  public static void sortArray01000() {
    final Integer[] array = array01000;
    quickSort(array, 0, array.length - 1);
  }

  @Benchmark
  public static void sortArray02000() {
    final Integer[] array = array02000;
    quickSort(array, 0, array.length - 1);
  }

  @Benchmark
  public static void sortArray04000() {
    final Integer[] array = array04000;
    quickSort(array, 0, array.length - 1);
  }

  @Benchmark
  public static void sortArray08000() {
    final Integer[] array = array08000;
    quickSort(array, 0, array.length - 1);
  }

  @Benchmark
  public static void sortArray16000() {
    final Integer[] array = array16000;
    quickSort(array, 0, array.length - 1);
  }

  private static void quickSort(Integer[] array, int lo, int hi) {
    if (hi <= lo) return;
    final int j = partition(array, lo, hi);
    quickSort(array, lo, j - 1);
    quickSort(array, j + 1, hi);
  }

  private static int partition(Integer[] array, int lo, int hi) {
    int i = lo, j = hi + 1;
    while (true) {
      while (compare(array[++i], array[lo]) < 0) // while (array[++i] < array[lo])
        if (i == hi) break;
      while (compare(array[lo], array[--j]) < 0) // while (array[lo] < array[--j])
        if (j == lo) break;
      if (i >= j) break;
      swapItems(array, i, j);
    }
    swapItems(array, lo, j);
    return j;
  }

  private static int compare(Integer v1, Integer v2) {
    cmpCount++;
    return v1.compareTo(v2);
  }

  private static void swapItems(Integer[] array, int i, int j) {
    swapCount++;
    final Integer tmp = array[i];
    array[i] = array[j];
    array[j] = tmp;
  }

}
而相同数组的
JMH
结果如下:

Benchmark                       Mode  Cnt      Score     Error  Units
QSortBenchmarks.sortArray_010k  avgt    5    109.454 ±   0.444  ms/op
QSortBenchmarks.sortArray_020k  avgt    5    373.518 ±  17.439  ms/op
QSortBenchmarks.sortArray_040k  avgt    5   1350.420 ±  26.733  ms/op
QSortBenchmarks.sortArray_080k  avgt    5   6519.015 ±  48.770  ms/op
QSortBenchmarks.sortArray_160k  avgt    5  26837.697 ± 926.132  ms/op
手工制作的基准所显示的数字与
nlog(N)
非常符合,看起来很逼真,并且与观察到的以秒为单位的执行时间非常一致。它们比
JMH
显示的数字小100到1000倍以上

下面是经过修改的
loopOverMethod()
,可通过该方法获得它们:

  private static void loopOverMethod(Consumer<Object> method) {
    for (int loops = 0; loops < 100; loops++ ) { // Kinda warmup
      initData();
      method.accept(null);
    }

    long time = 0;
    cmpTotal = 0; swapTotal = 0;
    for (int loops = 0; loops < LOOPS_TO_ITERATE; loops++ ) {
      initData();
      time -= System.nanoTime();
      method.accept(null);
      time += System.nanoTime();
      cmpTotal += cmpCount; swapTotal += swapCount;
    }
    System.out.printf("avrg time: \t%10.3f mks\n", 
                      time * 1e-3 / LOOPS_TO_ITERATE);
  }
手工制作的基准:

Rand seed = 123, rand limit = 1000000, iterations = 1000
sortArray_010k(): avrg time:    1060.951 mks
sortArray_020k(): avrg time:    2296.533 mks
sortArray_040k(): avrg time:    5021.629 mks
sortArray_080k(): avrg time:   10855.963 mks
sortArray_160k(): avrg time:   23335.923 mks

现在,结果看起来很合理,我对它们完全满意。

三点共同作用,不利于您的实施:

  • 快速排序的最坏情况复杂性为O(n^2)
  • 选择最左边的元素作为轴会在已排序的数组()上产生最坏的行为:
在quicksort的早期版本中,通常会选择分区最左边的元素作为轴心元素。不幸的是,这会导致已经排序的数组出现最坏情况

  • 您的算法在适当的位置对数组进行排序,这意味着在第一次传递后,将对“随机”数组进行排序。(计算JMH对数据进行多次传递的平均时间)
要解决这个问题,您可以更改基准测试方法。例如,您可以将
sortArray01000()
更改为

@Benchmark
public static void sortArray01000() {
  final Integer[] array = Arrays.copyOf(array01000, array01000.length);
  quickSort(array, 0, array.length - 1);
}
或者您可以修改
@Setup
注释,以便在每次调用基准方法之前执行该注释:

@Setup(Level.Invocation)
public static void initData() {
    //...
}

@Setup
注释采用一个参数,该参数确定何时执行Setup方法

这三个级别是():

  • Level.Trial
    :在每个基准测试之前
  • 级别.迭代
    :每次迭代前
  • Level.Invocation
    :在每次执行基准方法之前
默认级别为
level.Trial
()

这对你的考试意味着什么

要了解这一点,您必须了解JMH如何执行您的基准测试:

  • 它开始对您的基准测试方法之一进行测试
  • 在该试验期间,它进行了5次预热迭代和5次测量迭代
  • 在每次迭代过程中,它会在一个紧循环中调用您的基准测试方法,直到10秒过去——如果您的基准测试方法需要500秒,这意味着它将在每次迭代过程中被调用大约20000次,或者在整个试验过程中被调用大约200000次
现在有了
@Setup(Level.Trial)
和一个对输入数据进行排序的基准方法,这意味着只有快速排序方法的第一次调用才能显示
O(N log(N))
行为,所有剩余的调用都会在已经排序的数组上运行,并显示
O(N^2)
的最坏情况

使用
@Setup(Level.Iteration)
时,情况仍然没有改善多少-现在是每个迭代中第一次调用具有
O(N log(N))
行为的基准方法,每个迭代剩余的~20000次调用仍然显示
O(N^2)

使用
@Setup(Level.Invocation)
最后,基准方法的每次调用(以及快速排序的每次调用)都会得到自己的未排序数组作为输入,这在结果中清楚地显示出来:

@Setup(Level.Trial)

1000: 780 us
2000: 3300 us


@Setup(Level.Iteration)

1000: 780 us
2000: 3280 us
4000: 11700 us


@Setup(Level.Invocation)

1000: 58 us
2000: 124 us
4000: 280 us
通过我建议的更改(在基准方法中复制输入数组),我得到了稍微好一点的结果,但这可能是由于缓存效应造成的:

1000: 25 us
2000: 108 us
4000: 260 us

可能是缓存效应。这些数组太小了,这就是为什么计数可以工作,但时间测量却不能。试着用数以百万计的元素进行尝试。首先我要做的是第二大数组,它是“最外层”处理器缓存的大小,然后再试一次。@ FuryFART AFAIR,平均的比较数是2n*Ln(n)。在我提到的Sadgewick的书中有一个证据(再一次,AFAIR)。对于n=10002000。。。16000它给出了138163040466,3
@Benchmark
public static void sortArray01000() {
  final Integer[] array = Arrays.copyOf(array01000, array01000.length);
  quickSort(array, 0, array.length - 1);
}
@Setup(Level.Invocation)
public static void initData() {
    //...
}
@Setup(Level.Trial)

1000: 780 us
2000: 3300 us


@Setup(Level.Iteration)

1000: 780 us
2000: 3280 us
4000: 11700 us


@Setup(Level.Invocation)

1000: 58 us
2000: 124 us
4000: 280 us
1000: 25 us
2000: 108 us
4000: 260 us