JS快排算法尾递归溢出处理

1,233 阅读3分钟

之前有个印象,就是JS做了尾调优化。在实际工作中,不管是调研也罢、开发也罢,其实都没有接触到特别深层的递归尾调。正巧前几天跟人讨论递归的问题,拍着胸脯说“JS不用管尾调,只管用递归”,结果今天手写快排的时候,马上被打脸:尾递归溢出了。

尾递归溢出边界

const sum = (n) => {
    if(n > 1) {
        return n + sum(n - 1)
    } else {
        return n
    }
}
console.log(sum(10469))

在递归返回之前,所有的函数调用上下文(闭包)都会堆在堆里;等最后一次运算完成后,一层层退出。当层数过多时,就溢出了。

上面的sum递归,n值最大只能取到10469,我们称10469为n的溢出边界。

快排算法中的递归

快排的实现代码

众所周知,快排使用递归:

/**
 * 
 * @param {number[]} arr 
 */
const partition = (arr, left, right) => {
    const pivot = arr[left]
    while (left < right) {
        while (left < right && arr[right] >= pivot) {
            right--
        }
        arr[left] = arr[right];
        while (left < right && arr[left] <= pivot) {
            left++
        }
        arr[right] = arr[left];
    }
    arr[left] = pivot;
    return left;
}
/**
 * 
 * @param {number[]} arr 
 * @param {number} left 
 * @param {number} right 
 * @returns 
 */
const quick_sort = (arr, left = 0, right = arr.length - 1) => {
    if (left < right) {
        const middle = partition(arr, left, right);
        quick_sort(arr, left, middle - 1); // 递归左侧分区
        quick_sort(arr, middle + 1, right);  // 递归右侧分区
    }
    return arr
}

这个代码是原地排序,不会产生额外的空间复杂度。其实产生新数组会更好做一些,但考虑到原生的sort也是原地排序——虽然返回排序好的数组,但实际是对原数组排序——所以继续采用原地排序。

快排的大概思路是:初始取数组的第一个值作为分区轴pivot,以该轴为原点,将整个数组编排成左右两部分:左侧都小于轴,右侧都大于轴。然后对左右两部分递归处理,继续分区编排,直到整体排序完成。

跑一下计时看看性能表现:

【快速】排序1轮-随机数组_5000: 0.44ms
【快速】排序2轮-随机数组_5000: 0.438ms
------------------------------------------------
【快速】排序1轮-倒序数组_5000: 16.125ms
【快速】排序2轮-倒序数组_5000: 16.757ms
------------------------------------------------
【快速】排序1轮-正序数组_5000: 17.993ms
【快速】排序2轮-正序数组_5000: 15.742ms
------------------------------------------------

有序数组排序低效

从上面的跑分结果可以看出,无路是正序还是倒序,性能下降都挺严重的。下面分析一下原因:

设有数组变量arr包含4个元素[1,2,3,4]

  1. 取arr[0]作为分区轴pivot,将数组分为左边[1]和右边[2,3,4]两部分,递归+1。
  2. 右边取第一个元素作为分区轴,将数组拆分为[2][3,4],递归+1。
  3. 右边继续取第一个元素,拆分为[3][4],递归+1。
  4. 递归堆栈不断退出调用栈,直到完成排序。

时间复杂度为

( (1 + n) * n ) / 2 = 0.5n + 0.5n^2 = n^2

假设是乱序[2,4,3,1]

  1. [1,2][3,4]
  2. [1] [2][3] [4]
  3. 退出调用栈

时间复杂度为

O(log2~n)

最好情况下,也就是数组值分布均匀的时候,快排的时间复杂度是O(log2~n)。最差情况,也就是有序数组,时间复杂度是O(n^2)

这里的复杂度有多少,也就意味着有多少层递归等待释放;如果复杂度超过溢出边界,则会发生尾递归溢出。

上文中给出的快排算法,能排序的有序数组长度只能控制在5400左右,也就是说溢出边界在5400

快排优化

根据上面的分析可以知道,复杂度上升到n^2,与pivot取值、分区有直接关系。

如果我们在排序之前,扫描一下整个数组,计算出一个中间值作为pivot,让分区平衡,应当能解决这个问题。

取最大最小值的平均值作为轴

const get_pivot = (arr, start, end) => {
    if (start === end) {
        return arr[start]
    }
    let min = arr[start], max = min
    for (let i = start; i < end; i++) {
        if (arr[i] < min) {
            min = arr[i]
        } else if (arr[i] > max) {
            max = arr[i]
        }
    }
    return (min + max) / 2
}

当然也可以取AVG,但是担心数据类型溢出,所以改用最大最小值平均。

改造partition

/**
 * 
 * @param {number[]} arr 
 */
const partition = (arr, start, end) => {
    const pivot = get_pivot(arr, start, end)
    let left = start, right = end - 1, tmp = arr[left]
    while (left < right) {
        while (left < right && arr[right] >= pivot) {
            right--
        }
        if(left < right) {
            arr[left] = arr[right]
        }
        while (left < right && arr[left] < pivot) {
            left++
        }
        if(left < right) {
            arr[right] = arr[left]
        }
    }
    arr[left] = tmp
    if(tmp < pivot) {
        left += 1
    }
    return left
}

由于计算出的pivot不是从数组中取值,所以分区索引起止和逻辑需要做一些变化。

改造quick_sort

/**
 * 
 * @param {number[]} arr 
 * @param {number} start 
 * @param {number} end 
 * @returns 
 */
const quick_sort = (arr, start = 0, end = arr.length) => {
    const middle = partition(arr, start, end)
    if (middle > start) {
        quick_sort(arr, start, middle)
        quick_sort(arr, middle, end)
    }
    return arr
}

跑分

【快排优化】排序1轮-随机数组_5000: 0.789ms
【快排优化】排序2轮-随机数组_5000: 0.783ms
------------------------------------------------
【快排优化】排序1轮-倒序数组_5000: 0.351ms
【快排优化】排序2轮-倒序数组_5000: 0.359ms
------------------------------------------------
【快排优化】排序1轮-正序数组_5000: 0.341ms
【快排优化】排序2轮-正序数组_5000: 0.339ms
------------------------------------------------

随机数组的用时略高了一些,有序组数排序完全没有问题。

优化后的快排,能稳定在O(lnN)的复杂度。由于溢出边界为5400层,那么

log2~n = 5400
n = Infinity

优化后的快排在大数据范畴也不必担心尾递归溢出了。

和原生排序比较

优化后的快排,在大部分情况下都能以O(log2~n)的时间复杂度运行,效率应该是很高的。

据传JS原生的sort也是使用快排,我们与之做一下对比。

先写一个原生排序的方法,与上面的快排写法统一

const native_sort = (arr) => {
    return arr.sort((a, b) => a - b)
}

生成数据数量规模为1e4 2e4 1e5 1e6 1e7,我们只看一下1e7的规模,就比较清楚了:

【快排优化】排序1轮-随机数组_10000000: 2.281s
【快排优化】排序2轮-随机数组_10000000: 2.157s
------------------------------------------------
【快排优化】排序1轮-倒序数组_10000000: 887.603ms
【快排优化】排序2轮-倒序数组_10000000: 893.544ms
------------------------------------------------
【快排优化】排序1轮-正序数组_10000000: 865.073ms
【快排优化】排序2轮-正序数组_10000000: 863.914ms
------------------------------------------------
【原生】排序1轮-随机数组_10000000: 3.628s
【原生】排序2轮-随机数组_10000000: 3.662s
------------------------------------------------
【原生】排序1轮-倒序数组_10000000: 184.333ms
【原生】排序2轮-倒序数组_10000000: 210.318ms
------------------------------------------------
【原生】排序1轮-正序数组_10000000: 185.222ms
【原生】排序2轮-正序数组_10000000: 175.069ms
------------------------------------------------

随机数组排序,优化后的快排要快一点,当然也可能是因为原生排序用了函数作为比较方法,而优化的快排只能处理数字型数组,所以其实也没什么可比性。

有序数组排序,原生完胜,不明就里,如有高人清楚,请不吝赐教。

以上。