输入方差较小的并行快速排序中的java Stackoverflow异常
我已经使用Java ForkJoin
库实现了快速排序算法以实现并发性。我正在用大量随机生成的Integers
测试解决方案
当随机生成的Integers
的方差较大时,即random.nextInt()
,这一切都可以正常工作。但每当方差减小时,即random.nextInt() % 10
,我都会得到如下异常跟踪:
java.lang.StackOverflowError
at java.util.concurrent.ForkJoinTask.setExceptionalCompletion(ForkJoinTask.java:489) ...
测试。java
public static void main(String[] args) {
final int SIZE = 160_000;
Random rand = new Random();
Integer[] data = new Integer[SIZE];
for(int i = 0; i < data.length; i++) {
data[i] = rand.nextInt() % 10; // works for "rand.nextInt()", breaks with "% 10"
}
long t0 = System.currentTimeMillis();
QSort.sort(data);
long t1 = System.currentTimeMillis();
System.out.println("Sorted: " + QSort.isSorted(data));
System.out.println("Time elapsed: " + (t1-t0) + " ms");
}
QSort。java
public class QSort {
private static class QSortJob<T extends Comparable<T>> extends RecursiveAction {
private final T[] arr;
private final int left;
private final int right;
private QSortJob(T[] arr, int left, int right) {
this.arr = Objects.requireNonNull(arr);
this.left = left;
this.right = right;
}
@Override
protected void compute() {
if (left < right) {
int pivotIndex = left + (right - left) / 2;
pivotIndex = partition(pivotIndex);
invokeAll(new QSortJob<>(arr, left, pivotIndex-1),
new QSortJob<>(arr, pivotIndex+1, right));
}
}
private int partition(int pivotIndex) {
T pivotValue = arr[pivotIndex];
swap(pivotIndex, right);
int storeIndex = left;
for (int i=left; i<right; i++) {
if (arr[i].compareTo(pivotValue) < 0) {
swap(i, storeIndex);
storeIndex++;
}
}
swap(storeIndex, right);
return storeIndex;
}
private void swap(int i, int j) {
T tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
}
public static <T extends Comparable<T>> void sort(T[] arr) {
ForkJoinPool pool = new ForkJoinPool();
pool.invoke(new QSortJob<>(arr, 0, arr.length-1));
pool.shutdown();
}
为什么这会发生在一个小的输入方差上,有什么方法可以解决它
# 1 楼答案
当我们使用
% 10
时,会产生大量重复值,这就是问题的根源当子数组中的所有值相等时,会停止进一步的调用,并且通过减少不必要的调用,jun ran对3000000个元素没有任何问题
# 2 楼答案
这与快速排序算法在重复太多值时如何划分(子)数组有关。长话短说,您正越来越接近快速排序最糟糕的运行时行为,这会导致堆栈深度与要排序的数组大小成正比,而不是此大小的对数
分析
为了说明这一点,让我们看一个例子
让我们通过选择剩余的随机生成值除以2来简化示例。这让我们只关注两个不同的价值观
我们将在执行快速排序的同时打印以下信息,以帮助我们进行调查:
depth
,这是递归中堆栈的深度(为了简单起见,我们将忽略fork-join框架发出的其他调用,这不会影响分析),branch
,这就是我们是在分区子数组的左侧还是右侧操作,以及这个子数组的length
:第一个电话看起来像:
让我们用以下公式生成值的分布:
我运行了一个大小为100000的程序——这足以让我重现堆栈溢出。让我们看看第一次通话的记录:
QSortJob#compute
时发生了什么?我们有一个子数组,它是原始数组的长度减去1。根据我们对算法的理解,我们可以由此得出结论,分区方法找到了pivot的值0
,因为数组中的所有值都是>= 0
,因此没有一个值“移动”到pivot的左侧,因此pivot保持在其初始位置,即索引0,右边数组的大小变成初始大小减1李>1
被选为轴之后生成的。在0
和1
的“合理”均匀分布的假设下,我们的0
大约与1
一样多,这解释了左和右子阵列的49882
和99997 - 49882 = 50115
的大小,分别填充了唯一的值0
或1
李>100,000 / 2 = 50,000
的递归调用,从而过度填充堆栈李>这种分析可以转化为这样一种情况:我们将随机生成的值的剩余部分除以10。这就为输入数组留下了一组大小为
160,000
的值{-9, -8, -7, -6, -5, -4, -3, -2, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
,在均匀分布的假设下,这就给我们留下了数组中每个值的160000 / 19 ~= 8421
次出现。让我们重现我们之前采用的推理:在递归过程中的某个时刻,我们将这些值中的每一个分离成大小为8421的数组,从那里开始,算法将调用自己8421次,再次溢出堆栈结论
正如我们刚才看到的,快速排序算法,由于其分区方案,对要排序的数组的内容是合理的。因此,它是“易受攻击的”,无法为每个输入提供有保证的、一致的运行时复杂性
一个典型的例子可以说明这一点,它是一个已经排序的数组,或者我们可以选择一个填充了唯一值的数组:
进一步的分析和评论
这当然不是致命的:您的算法可以被调整以检测这些“边缘”情况,从而切换到另一种策略,避免深度、低效的递归调用。如果你愿意,我可以进一步描述我的意思