为什么这个快速排序算法在几乎排序好的列表和已排序的列表上会导致堆栈溢出?

6
我正在使用Java编写快速排序算法,以对随机整数数组进行排序并使用System.nanoTime()计时。这些数组的大小是10的幂次方,从10^3开始,到10^7结束。此外,这些随机列表具有不同的属性。我正在对纯随机列表、带有一些相同值的列表(fewUnique)、反向排序的列表、已排序的列表和几乎排序的列表进行排序。
排序运作正常。它在数组上递归地执行快速排序,直到需要排序30个元素或更少的数组,在这种情况下,它会执行插入排序。
对于10^3和10^4来说都很好,但一旦到达10^5,它只能对随机、少量唯一和随机列表进行排序,而在排序几乎排序和已排序的列表时会遇到堆栈溢出错误。
我不认为问题在于列表生成方式,因为堆栈溢出发生在排序算法中(编译器引用的行是findPivot()方法中的第一行)。此外,它通常会在崩溃之前对1到6个列表进行排序。因此,它必须以某种方式与几乎排序和已排序的列表交互。此外,生成反转列表涉及调用创建已排序列表的代码(然后反转它)。
此外,我认为问题不太可能仅仅是由于算法需要以某种原因在几乎排序和已排序列表中通过递归将数组的某些部分划分出来的次数要比其他列表类型显著地多,因为它可以对具有10^7值的随机列表进行排序,这将需要比具有10^5值的几乎排序列表更多的划分。
我意识到这必须与这些列表类型如何与我的快速排序递归交互有关,但如果有人能够解释一下就太好了。我已经将完整的快速排序算法代码和随机列表生成器代码放在下面。
/**
 * Performs a quick sort given the indexes of the bounds of an integer array
 * @param arr The array to be sorted
 * @param highE The index of the upper element
 * @param lowE The index of the lower element
 */
public static void quickSort(int[] arr, int highE, int lowE)
{       
    //Only perform an action if arr.length > 30, otherwise insertion sort [recursive base case])
    if (lowE + 29 < highE)
    {
        //Get the element and then value of the pivot
        int pivotE = findPivot(arr, highE, lowE);
        int pivotVal = arr[pivotE], storeE = lowE;

        //Swap the pivot and the last value.
        swapElements(arr, pivotE, highE);

        //For each element in the selection that is not the pivot, check to see if it is lower than the pivot and if so, move it to the leftmost untouched element.
        for (int i = lowE; i < highE; i++)
        {
            if (arr[i] < pivotVal)
            {
                swapElements(arr, storeE, i);

                //Increment storeE so that the element that is being switched moves to the right location
                storeE++;
            }
        }

        //Finally swap the pivot into its proper position and recrusively call quickSort on the lesser and greater portions of the array
        swapElements(arr, storeE, highE);                   
        //Lesser
        quickSort(arr, storeE - 1, lowE);
        //Greater
        quickSort(arr, highE, storeE + 1);
    }
    else
    {
        insertSort(arr, highE, lowE);
    }
}




/**
 * Finds the pivot element
 * @param arr The array to be sorted
 * @param highE The index of the top element
 * @param lowE The index of the bottom element
 * @return The index of the pivot.
 */
public static int findPivot(int[] arr, int highE, int lowE)
{
    //Finds the middle element
    int mid = (int) Math.floor(lowE + (highE - lowE) / 2);

    //Returns the value of the median of the first, middle and last elements in the array.
    if ((arr[lowE] >= arr[mid]) && (arr[lowE] >= arr[highE])) 
    {
        if (arr[mid] > arr[highE]) {return mid;}
        else {return highE;}
    }
    else if ((arr[mid] >= arr[lowE]) && (arr[mid] >= arr[highE])) 
    {
        if (arr[lowE] > arr[highE]) {return lowE;}
        else {return highE;}
    }
    else 
    {
        if (arr[lowE] > arr[mid]) {return lowE;}
    }

    return mid;
}




/**
 *Performs an insertion sort on part of an array
 * @param arr The array to be sorted.
 * @param highE The index of the top element.
 * @param lowE The index of the low element.
 */
public static void insertSort(int[] arr, int highE, int lowE)
{
    //Sorts elements lowE to i in array, with i being gradually incremented.
    for (int i = lowE + 1; i <= highE; i++)
    {
        for (int j = i; j > lowE; j--)
        {
            if (arr[j] < arr[j - 1])
            {
                swapElements(arr, j, j-1);
            }
            else {break;}
        }
    }
}

随机列表生成器

/**
 * Creates a random list
 * @param arr The array to place the list inside of
 */
public static void randomList(int[] arr)
{
    //Places a random number at each element of the array

    for (int i = 0; i < arr.length; i++)
    {
        arr[i] = (int) Math.floor(Math.random() * RAND_MAX);
    }
}




/**
 * Creates a nearly sorted list of random numbers
 * @param arr the array to place the list inside of
 */
public static void nearSortList(int[] arr)
{
    //Creates a sorted list in arr
    sortList(arr);



    int swaps = (int) (Math.ceil(Math.pow((Math.log(arr.length)), 2.0)));

    //The two values to be switched each time
    int a, b;

    //Performs a number of swaps equal to swaps [log(N) ^ 2] rounded up, with numbers switched no more than ln(N) places away
    for (int i = 0; i < swaps; i++)
    {
        a = (int) Math.floor(Math.random() * arr.length);

        b = (int) (a + Math.random() * 2 * Math.log(arr.length) - Math.log(arr.length));

        //Accounts for cases in which b is either greater or smaller than the array bounds
        if (b < 0)
        {
            b = -b;
        }
        else if (b >= arr.length)
        {
            b = -1 * (arr.length - b);
        }

        swapElements(arr, a, b);
    }
}




/**
 * Creates a random list with many unique values in
 * @param arr the array to place the list inside of
 */
public static void fewUniqueList(int[] arr)
{
    int[] smallArr = new int[(int) Math.floor(Math.pow(arr.length, 9.0 / 10.0))];


    //Creates a smaller array of random values
    randomList(smallArr);



    //Fills the larger list up with values from the smaller list, ensuring aproximately N / N ^ (9/10) instances of each number in the array and ensuring, at most, there are N ^ (9/10) (rounded down) unique values in the large array
    for (int i = 0; i < arr.length; i++)
    {
        arr[i] = smallArr[(int) Math.floor(Math.random() * smallArr.length)];
    }
}




/**
 * Creates a reversed list of random numbers
 * @param arr the array to place the list inside of
 */
public static void reversedList(int[] arr)
{
    //Creates a sorted list in arr
    sortList(arr);




    //Switches each ith elements with its mirror on the other end of the array until the value of i reaches the middle of the array
    for (int i = 0; i < (int) (arr.length / 2.0); i++)
    {
        swapElements(arr, i, arr.length - 1 - i);
    }
}




/**
 * Creates a sorted list of random numbers using a merge sort
 * @param arr the array to place the list inside of
 */
public static void sortList(int[] arr)
{
    //Creates a random list in arr
    randomList(arr);

    Arrays.sort(arr);
}

编辑: [已废弃]

编辑2:

我已经用以下代码替换了基本递归调用,只调用了最小的两个分区,根据EJPs的建议,但仍然没有解决问题。

if (storeE - 1 - lowE < highE - storeE + 1)
{
    //Lesser
    quickSort(arr, storeE - 1, lowE);
    //Greater
    quickSort(arr, highE, storeE + 1);
}
else
{
    //Greater
    quickSort(arr, highE, storeE + 1);
    //Lesser
    quickSort(arr, storeE - 1, lowE);
}

编辑3:

好的,现在很明显递归深度比我原先估计的要大得多,对于几乎排序和已排序的列表。但现在我需要找出这种情况的原因,以及为什么随机列表只有1000万个值时深度为28,而几乎排序和已排序的列表深度超过了3000。


你能添加一个计数器来检查你达到的最大递归深度吗?鉴于你选择枢轴的方式,这似乎是非常不可能的,但值得一检查... - chill
我来晚了,但三数取中枢轴选择法确实非常有效地消除了这个太常见的问题。 - Jeremy West
5个回答

4
对于一个随机数组,你可以将数据分成大块。
但是对于(几乎)排序的数组,你主要会一次性分割一个元素。
因此,对于排序的数组,你的堆栈大小最终将与数组大小相同,而对于随机数组,它更有可能是该大小的对数。
因此,即使随机数组比几乎排序的数组大得多,较小的数组抛出异常并不令人惊讶,而较大的数组则不会。
修改您的代码
就修复而言,正如EJP指出的那样,您应该先执行较小的分区以限制堆栈增长。但这本身并不能解决问题,因为Java不支持尾调用优化 (嗯,我理解那个问题是可选的实现)。
这里一个相当简单的解决方法是将函数放入while循环中,从而硬编码尾调用优化。
为了更好地说明我的意思:
public static void quickSort(int[] arr, int highE, int lowE)
{
    while (true)
    {
        if (lowE + 29 < highE)
        {
            ...
            quickSort(arr, storeE - 1, lowE);

            // not doing this any more
            //quickSort(arr, highE, storeE + 1);

            // instead, simply set the parameters to their new values
            // highE = highE;
            lowE = storeE + 1;
        }
        else
        {
            insertSort(arr, highE, lowE);
            return;
        }
    }
}

好的,现在你已经有了基本的想法,这会更好(在功能上等同于上面的代码,只是更加简洁):

public static void quickSort(int[] arr, int highE, int lowE)
{
    while (lowE + 29 < highE)
    {
        ...
        quickSort(arr, storeE - 1, lowE);
        lowE = storeE + 1;
    }
    insertSort(arr, highE, lowE);
}

当然,这并不实际上先执行较小的那一个,但我会让你自己想出来(看起来你已经有了如何做到这一点的相当好的想法)。 工作原理 对于一些虚构的值...
您当前的代码执行以下操作:(缩进表示在该函数调用内发生的事情 - 因此增加缩进意味着递归)
quickSort(arr, 100, 0)
   quickSort(arr, 49, 0)
      quickSort(arr, 24, 0)
         insertion sort
      quickSort(arr, 49, 26)
         insertion sort
   quickSort(arr, 100, 51)
      quickSort(arr, 76, 0)
         insertion sort
      quickSort(arr, 100, 74)
         insertion sort

修改后的代码执行以下操作:
quickSort(arr, 100, 0)
   quickSort(arr, 49, 0)
      quickSort(arr, 24, 0)
         break out of the while loop
         insertion sort
   lowE = 26
   break out of the while loop
      insertion sort
lowE = 51
run another iteration of the while-loop
    quickSort(arr, 76, 0)
      break out of the while loop
      insertion sort
lowE = 74
break out of the while loop
   insertion sort

增加堆栈大小

不确定您是否考虑过这一点,或者它是否适用于您的参数,但您可以始终考虑使用-Xss命令行参数增加堆栈大小


那不会造成一个无限循环吗? - Zach Beavon-Collin
不应该这样 - 您在每一步都更改了 lowE(并在 else 中返回)。这应该与递归版本产生相同的结果(除了异常)。 - Bernhard Barker
除了lowE不是静态变量。它们将在函数的第一次调用中保持不变,而该函数将继续循环。另外,只是为了澄清,我指的是更好的版本。 - Zach Beavon-Collin
这里的第一个调用与您代码中的第一个调用完全相同 - 如果您发现该函数正常工作,则应该明白它不会创建无限循环(因为该调用只是再次调用此函数)。请注意,我发布的两个版本之间几乎没有什么区别 - 只需检查 if 语句条件与 while 循环条件为 true 时会发生什么即可。我不想只发布第二个版本,因为似乎更难理解我是如何得到它的。我编辑了我的帖子以显示发生了什么 - 希望这有助于更好地解释它。 - Bernhard Barker

4
在[ACP]中,唐纳德·科努斯建议始终先将两个分区中较大的一个移动,然后立即对较小的一部分进行排序,以限制堆栈增长。在您的代码中,这相当于首先递归地对两个分区中较小的那一个进行排序,然后再进行另一个。

好的,我会试着实现它。 - Zach Beavon-Collin
2
这难道不只在进行尾递归优化时有用吗,而Java并不这样做吗? - Bernhard Barker
2
我相信这个想法是在较大的分区上使用尾递归。Java不会将尾递归调用优化出调用栈,因此这并没有帮助。 - Floegipoky
2
@EJP 在较小的分区上递归和在较大的分区上递归是完全独立的 - 先对较小的进行排序并不会导致对较大的排序工作量减少。除非我漏掉了什么,否则顺序只有在进行尾递归优化时才重要。 - Bernhard Barker
3
抱歉,@EJP说的并不正确。使用尾递归消除,可以保证堆栈大小不会超过O(logn)。如果没有尾递归消除,你的调用树的最大深度仍然是O(n),只是看起来像倒转的Verizon符号。 - Floegipoky
显示剩余11条评论

1
"

StackOverflowError 最可能与递归深度过深有关。随着要排序的元素越来越多,您的快速排序必须在进入插入排序部分之前执行更多的递归调用 quicksort()。在某个点上,这种递归太深了,堆栈上的方法调用太多。

可能是对已排序列表进行递归导致更深的递归,因此比对未排序列表进行排序更早崩溃。这取决于具体实现。

对于非学术和非学习目的,始终最好使用命令式样式实现这些算法,而不是使用递归。

"

问题在于我不明白为什么会发生这种情况,因为在chill的建议下,我实现了一个计数器,使用快速排序递归对一个包含10^7个随机数的列表进行排序,递归了数十万次。其他类型的列表使用更多的递归并深入得多,但却没有崩溃相同的代码,所以这种情况是没有道理的。 - Zach Beavon-Collin
1
无论它被调用了多少次都不重要,重要的是您所达到的最大递归深度。 伤害的是深度而不是递归调用发生的次数。 计算它被调用的次数是无意义的。 您需要一个深度计数器。 而且你在这里感兴趣的是最大深度。 - Fabian Barney
好的,我明白了。看起来你绝对是正确的。在一个随机列表中,它要对10^7个值进行28层深度的操作,但几乎排序时会增加到3000层。现在我只需要弄清楚原因。 - Zach Beavon-Collin
这意味着由于某种原因它会退化。当较小和较大的部分大小相等且具有完美的枢轴时,快速排序表现最佳。最坏情况是枢轴元素不好,而所有其他元素都小于枢轴或大于枢轴。我会在递归调用quicksort之前使用条件断点调试代码,并查看一个比另一个更多元素的较大/较小部分中的值。然后查看数组相关部分的值。 - Fabian Barney

0

检查是否存在相同元素的长运行。分区部分:

for (int i = lowE; i < highE; i++)
{
    if (arr[i] < pivotVal)
    {
        swapElements(arr, storeE, i);
        storeE++;
    }
}

将一个包含相同元素的列表以最差的方式进行分割。

我可以看一下。唯一的问题是,我已经在测试带有几个相同值的随机列表(请参见fewUnique),虽然我可以进一步优化算法,但它肯定不会像nearlySorted那样深入递归,后者并不一定具有任何相同的值。 - Zach Beavon-Collin

0

在排序或接近排序的数据集中,快速排序表现出O(n^2)的最坏情况运行时间。当N的值很大时,递归树会变得非常深,以至于系统堆栈耗尽无法产生更多的递归。通常这样的算法应该采用迭代方法而不是递归方法来实现。


是的,但正如我在帖子中所说,我需要弄清楚为什么对于几乎排序和排序列表,它会深入到这个程度,而对于其他类型的列表(例如随机列表)则不会深入到这个程度。 - Zach Beavon-Collin
因为在排序列表中,您取第一个或最后一个元素,其余元素都在一侧,因此堆栈每个列表项增加1。分而治之并不起作用。在随机列表中,平均值为50/50,50%在较小的一侧,50%在较大的一侧,因此堆栈按log n增长,而不是n。 - Roman Rabinovich

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