在解决 Sarafi 中 sort 方法问题时,笔者没有考虑时间复杂度的问题,使用 O(n2) 的排序算法进行重写,在实际产品环境中引起不小的性能问题。git
阅读 v8 array.js 源码(Array.js)后发现,Chrome 在实现 sort 方法时对小数组(length <= 10)进行插入排序,对大数组进行快速排序 O(nlogn),来下降该方法的时间复杂度。github
快速排序的核心是不断把原数组作切割,切割成小数组后再对小数组进行相同的处理,这是一种典型的分治的算法设计思路,选取数组中第一个元素做为基准,可对其进行简单实现以下:web
function QuickSort(arr, func) { if (!arr || !arr.length) return []; if (arr.length === 1) return arr; var pivot = arr[0]; var smallSet = []; var bigSet = []; for (var i = 1; i < arr.length; i++) { if (func(arr[i], pivot) < 0) { smallSet.push(arr[i]); } else { bigSet.push(arr[i]); } } return basicSort(smallSet, func).concat([pivot]).concat(basicSort(bigSet, func)); }
上面的算法建立一个新的数组做为计算结果,从空间使用的角度看是不经济的,Javascript 的快速排序算法中并无像上面的代码那样建立一个新的数组,而是在原数组的基础上,经过交换元素位置实现排序,故而相似于 push、 pop、 splice 这几个方法,sort 方法也是会修改原数组对象的。算法
function swap(arr, from, to) { if (from === to) return; let temp = arr[from]; arr[from] = arr[to]; arr[to] = temp; } function QuickSortWithPartition(arr, fn, from, to) { from = from === void 0 ? 0 : from; to = to === void 0 ? arr.length : to; if (from >= to - 1) { return arr; } let pivot = arr[from]; let smallIndex = from; let bigIndex = from + 1; for (; bigIndex < to; bigIndex++) { if (fn(arr[bigIndex], pivot) < 0) { smallIndex++; swap(arr, smallIndex, bigIndex); } } swap(arr, smallIndex, from); QuickSortWithPartition(arr, fn, from, smallIndex - 1); QuickSortWithPartition(arr, fn, smallIndex + 1, to); return arr; }
其中,from 是起始索引,to 是终止索引,若是这两个参数缺失,则表示处理整个数组。数组
由于上面的分区过程当中,大数分区和小数分区都是从左向右增加,其实咱们能够考虑从两侧向中间遍历,这样能有效地减小交换元素的次数。举个例子,假如咱们有一个数组 [2, 1, 3, 1, 3, 1, 3],采用上面的分区算法一共会碰到三次比基准元素小的状况,因此会发生三次交换;而若是咱们换个思路,把从右往左找到小于基准的元素,和从左往右找到大于基准的元素交换,这个数组只须要交换一次便可完成排序(把第一个3和最后一个1交换)。浏览器
function QuickSortWithPartitionOp(arr, fn, from, to) { from = from === void 0 ? 0 : from; to = to === void 0 ? arr.length : to; if (from >= to - 1) { return arr; } let pivot = arr[from]; let smallEnd = from; let bigBegin = to - 1; while (smallEnd < bigBegin) { while (fn(arr[bigBegin], pivot) >= 0 && smallEnd < bigBegin) { bigBegin--; } while (fn(arr[smallEnd], pivot) <= 0 && smallEnd < bigBegin) { smallEnd++; } if (smallEnd < bigBegin) { swap(arr, smallEnd, bigBegin); } } swap(arr, smallEnd, from); QuickSortWithPartitionOp(arr, fn, from, smallEnd - 1); QuickSortWithPartitionOp(arr, fn, smallEnd + 1, to); return arr; }
快速排序算法平均时间复杂度是 O(nlogn),但它的最差状况下时间复杂度会增大到 O(n2),其性能好坏的关键就在于分区是否合理:若是每次都能平均分红相等的两个分区,那么只须要 logn 层递归;而若是每次分区都不合理,总有一个分区是空的,则须要 n 层迭代。app
对于一个内容随机的数组而言,不太可能出现最差状况,但平常处理的数组每每并非内容随机的,一种很容易的解决方案是不要选取固定位置的元素做为基准元素,而是随机从数组里挑出一个元素做为基准元素,这样能够极大几率地避免最差状况,然而这并不等于避免最差状况,特别是在数组很大的时候,更要求咱们更谨慎地选取基准元素。性能
三数取中法是挑选基准元素的一种经常使用方法:即挑选基准元素时,先把第一个元素、最后一个元素和中间一个元素挑出来,这三个元素中大小在中间的那个元素就被认为是基准元素。优化
function getPivot(arr, fn, from, to) { let mid = (from + to) >> 1; if (fn(arr[from], arr[mid]) < 0) { swap(arr, from, mid); } if (fn(arr[from], arr[to]) > 0) { swap(arr, from, to); } if (fn(arr[to], arr[mid]) < 0) { swap(arr, to, mid); } return arr[from]; }
其余比较典型的取中值手段包括:ui
v8 源码中的基准元素选取更为复杂:若是数组长度不超过1000,则进行基本的三数取中;若是数组长度超过1000,那么 v8 的处理是除去首尾的元素,对剩下的元素每隔200左右挑出一个元素,对这些元素排序,找出中间的那个,并用这个元素跟原数组首尾两个元素一块儿进行三数取中。
设想一下,一个数组里若是全部元素都相等,基准元素无论怎么选都是同样的,那么在分区的时候,必然出现除基准元素外的其余元素都被分到同一个分区的状况,进入最差性能的 case。
那么对于重复元素应该怎么处理呢?
从性能的角度,若是发现一个元素与基准元素相同,那么它应该被记录下来,避免后续再进行没必要要的比较。
function QuickSortWithPartitionDump(arr, fn, from, to) { from = from === void 0 ? 0 : from; to = to === void 0 ? arr.length - 1 : to; if (from >= to) { return arr; } let pivot = getPivot(arr, fn, from, to); let smallEnd = from; let bigBegin = to; let i = from + 1; while (i <= bigBegin) { let r = fn(arr[i], pivot); if (r < 0) { swap(arr, smallEnd++, i++); } else if (r > 0) { swap(arr, i, bigBegin--); } else { i += 1; } } QuickSortWithPartitionDump(arr, fn, from, smallEnd - 1); QuickSortWithPartitionDump(arr, fn, bigBegin + 1, to); return arr; }
对于小数组,可能使用快速排序的速度还不如平均复杂度更高的插入排序,故而出于减小递归深度的考虑,数组长度较小时,使用插入排序算法。
function insertSort(arr, fn, from, to) { for (let i = from; i < to; i++) { for (let j = i + 1; j < to; j++) { let t = fn(arr[i], arr[j]); let r = (typeof t === 'number' ? t : t ? 1 : 0) > 0; if (r) { let tmp = arr[i]; arr[i] = arr[j]; arr[j] = tmp; } } } return arr; }
快速排序若是递归太深的话很能够出现“爆栈”,上面提到的对小数组采用插入排序算法,以及采用内省排序算法均可以减小递归深度,不过 v8 引擎中还作了一些不太常见的优化:每次分区后,v8 引擎会选择元素少的分区进行递归,而将元素多的分区直接经过循环处理,无疑能够大大减少递归深度。
因为快速排序时间复杂度的不稳定性,David Musser 于1997设计了内省排序法(Introsort),这个算法在快速排序的基础上,监控递归的深度:一旦长度为 n 的数组通过 logn 层递归(快速排序算法最佳状况下的递归层数)尚未结束的话,就认为此次快速排序的效率可能不理想,转而将剩余部分换用其余排序算法,一般使用堆排序算法(Heapsort,最差时间复杂度和最优时间复杂度均为 O(nlogn))。
笔者发现 Safari 或者 iPhone 中 sort 方法不生效(不一样浏览器实现机制差别),故判断后进行该方法的重写处理,代码以下:
;(function(w){ if(/msie|applewebkit.+safari/i.test(w.navigator.userAgent)){ var _sort = Array.prototype.sort; Array.prototype.sort = function(fn){ if(!!fn && typeof fn === 'function'){ if(this.length < 2) return this; var i = 0, j = i + 1, l = this.length, tmp, r = false, t = 0; for(; i < l; i++){ for(j = i + 1; j < l; j++){ t = fn.call(this, this[i], this[j]); r = (typeof t === 'number' ? t : !!t ? 1 : 0) > 0 ? true : false; if(r){ tmp = this[i]; this[i] = this[j]; this[j] = tmp; } } } return this; } else { return _sort.call(this); } }; } })(window);