之前有个印象,就是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]
:
- 取arr[0]作为分区轴
pivot
,将数组分为左边[1]
和右边[2,3,4]
两部分,递归+1。 - 右边取第一个元素作为分区轴,将数组拆分为
[2]
和[3,4]
,递归+1。 - 右边继续取第一个元素,拆分为
[3]
和[4]
,递归+1。 - 递归堆栈不断退出调用栈,直到完成排序。
时间复杂度为
( (1 + n) * n ) / 2 = 0.5n + 0.5n^2 = n^2
假设是乱序[2,4,3,1]
:
[1,2]
和[3,4]
[1]
[2]
和[3]
[4]
- 退出调用栈
时间复杂度为
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
------------------------------------------------
随机数组排序,优化后的快排要快一点,当然也可能是因为原生排序用了函数作为比较方法,而优化的快排只能处理数字型数组,所以其实也没什么可比性。
有序数组排序,原生完胜,不明就里,如有高人清楚,请不吝赐教。
以上。