深刻了解javascript的sort方法

写于2015年6月18日,可能已过期,请谨慎参考。
全部示例代码未经完整测试,仅示意思路。
在javascript中,数组对象有一个有趣的方法 sort,它接收一个类型为函数的参数做为排序的依据。这意味着开发者只须要关注如何比较两个值的大小,而不用管“排序”这件事内部是如何实现的。不过了解一下sort的内部实现也不是一件坏事,何不深刻了解一下呢?

算法课上,咱们会接触不少种排序算法,什么冒泡排序、选择排序、快速排序、堆排序等等。那么javascript的 sort方法采用哪一种排序算法呢?要搞清楚这个问题,呃,直接看 v8源代码好了。v8中对 Array.sort的实现是采用javascript完成的,粗看下来,使用了快速排序算法,但明显比咱们熟悉的快速排序要复杂。那么到底复杂在什么地方?为何要搞这么复杂?这是咱们今天要探讨的问题。

快速排序算法

快速排序算法之因此被称为快速排序算法,是由于它能达到最佳和平均时间复杂度均为O(n·logn),是一种应用很是普遍的排序算法。它的原理并不复杂,先找出一个基准元素(pivot,任意元素都可),而后让全部元素跟基准元素比较,比基准元素小的,放到一个集合中,其余的放到另外一个集合中;再对这两个集合执行快速排序,最终获得彻底排序好的序列。

因此快速排序的核心是不断把原数组作切割,切割成小数组后再对小数组进行相同的处理,这是一种典型的分治的算法设计思路。实现一个简单的快速排序算法并不困难。咱们不妨试一下:
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 QuickSort(smallSet, func)
           .concat([pivot])
           .concat(QuickSort(bigSet, func));
}
复制代码

这是一个很是基础的实现,选取数组的第一项做为基准元素。javascript

原地(in-place)排序

咱们能够注意到,上面的算法中,咱们实际上是建立了一个新的数组做为计算结果,从空间使用的角度看是不经济的。javascript的快速排序算法中并无像上面的代码那样建立一个新的数组,而是在原数组的基础上,经过交换元素位置实现排序。因此,相似于push、pop、splice这几个方法, sort方法也是会修改原数组对象的!java

咱们前面说过,快速排序的核心在于切割数组。那么若是只是在原数组上交换元素,怎么作到切割数组呢?很简单,咱们并不须要真的把数组切割出来,只须要记住每一个部分起止的索引号。举个例子,假设有一个数组[12, 4, 9, 2, 18, 25],选取第一项12为基准元素,那么按照原始的快速排序算法,会把这个数组切割成两个小数组:[4, 9, 2], 12, [18, 25]。可是咱们一样能够不切割,先经过比较、交换元素,将原数组修改为[4, 9, 2, 12, 18, 25],再根据基准元素12的位置,认为0~2号元素是一组,4~5号元素是一组,为了表述方便,我这里将比基准元素小的元素组成的分区叫小数分区,另外一个分区叫大数分区。这很像电脑硬盘的分区,并非真的把硬盘分红了C盘、D盘,而是记录下一些起止位置,在逻辑上分红了若干个分区。相似的,在快速排序算法中,咱们也把这个过程叫作分区(partition)。因此相应的,我也要修改一下以前的说法了,快速排序算法的核心是分区。git

说了这么多,仍是实现一个带分区的快速排序吧: github

function swap(arr, from, to) {
    if (from == to) return;
    var temp = arr[from];
    arr[from] = arr[to];
    arr[to] = temp;
}
 
function QuickSortWithPartition(arr, func, from, to) {
    if (!arr || !arr.length) return [];
    if (arr.length === 1) return arr;
    from = from === void 0 ? 0 : from;
    to = to === void 0 ? arr.length - 1 : to;
    var pivot = arr[from];
    var smallIndex = from;
    var bigIndex = from + 1;
    for (; bigIndex <= to; bigIndex++) {
        if (func(arr[bigIndex], pivot) < 0) {
            smallIndex++;
            swap(arr, smallIndex, bigIndex);
        }
    }
    swap(arr, smallIndex, from);
    QuickSortWithPartition(arr, func, from, smallIndex - 1);
    QuickSortWithPartition(arr, func, smallIndex + 1, to);
    return arr;
}
复制代码

看起来代码长了不少,不过并不算复杂。首先因为涉及到数组元素交换,因此先实现一个 swap方法来处理元素交换。快速排序算法中,增长了两个参数, from和 to,分别表示当前要处理这个数组的哪一个部分, from是起始索引, to是终止索引;若是这两个参数缺失,则表示处理整个数组。算法

一样的,我用最简单的方式选取基准元素,即所要处理分区的第一个元素。而后我定义了smallIndex和 bigIndex两个变量,分别表示的是左侧小数分区的终止索引和右侧大数分区的终止索引。什么意思?就是说从第一个元素(基准元素)到第 smallIndex个元素间的全部元素都比基准元素小,从第 smallIndex + 1到第 bigIndex个元素都比基准元素大。一开始没有比较时,很显然这两部分分区都是空的,而比较的过程很简单,直接是 bigIndex向右移,一直移到分区尾部。每当 bigIndex增长1,咱们会进行一次判断,看看这个位置上的元素是否是比基准元素大,若是大的话,不用作处理,它已经处于大数分区了;但若是比基准元素小,就须要进行一次交换。怎么交换呢?首先将 smallIndex增长1,意味着小数分区增长了一个元素,但此时 smallIndex位置的元素很明显是一个大数(这个说法其实不对,若是以前大数分区里面没有元素,此时 smallIndex和bigIndex相等,但对交换没有影响),而在 bigIndex位置的元素是一个小数,因此只要把这两个位置的元素交换一下就行了。编程

最后可别忘了一开始的起始元素,它的位置并不正确,不过只要将它和 smallIndex位置的元素交换位置就能够了。同时咱们获得了对应的小数分区 [from...smallIndex – 1]和大数分区[smallIndex + 1…to]。再对这两个分区递归排序便可。数组

分区过程的优化

上面的分区过程(仅仅)仍是有必定的优化空间的,由于上面的分区过程当中,大数分区和小数分区都是从左向右增加,其实咱们能够考虑从两侧向中间遍历,这样能有效地减小交换元素的次数。举个例子,例如咱们有一个数组 [2, 1, 3, 1, 3, 1, 3],采用上面的分区算法,一共碰到三次比基准元素小的状况,因此会发生三次交换;而若是咱们换个思路,把从右往左找到小于基准和元素,和从左往右找到大于基准的元素交换,这个数组只须要交换一次就能够了,即把第一个3和最后一个1交换。性能优化

咱们也来尝试写一下实现: markdown

function QuickSortWithPartitionOp(arr, func, from, to) {
    if (!arr || !arr.length) return [];
    from = from === void 0 ? 0 : from;
    to = to === void 0 ? arr.length - 1 : to;
    if (from >= to - 1) return arr;
    var pivot = arr[from];
    var smallEnd = from + 1;
    var bigBegin = to;
    while (smallEnd < bigBegin) {
        while (func(arr[bigBegin], pivot) > 0 && smallEnd < bigBegin) {
            bigBegin--;
        }
        while (func(arr[smallEnd], pivot) < 0 && smallEnd < bigBegin) {
            smallEnd++;
        }
        if (smallEnd < bigBegin) {
            swap(arr, smallEnd, bigBegin);
        }
    }
    swap(arr, smallEnd, from);
    QuickSortWithPartitionOp(arr, func, from, smallEnd - 1);
    QuickSortWithPartitionOp(arr, func, smallEnd + 1, to);
    return arr;
}
复制代码

分区与性能

前面咱们说过,快速排序算法平均时间复杂度是O(logn),但它的最差状况下时间复杂度会衰弱到O(n2)。而性能好坏的关键就在于分区是否合理。若是每次都能平均分红相等的两个分区,那么只须要logn层迭代;而若是每次分区都不合理,总有一个分区是空的,那么须要n层迭代,这是性能最差的场景。函数

那么性能最差的场景会出现吗?对于一个内容随机的数组而言,不太可能出现最差状况。但咱们平时在编程时,处理的数组每每并非内容随机的,而是极可能预先有必定顺序。设想一下,若是一个数组已经排好序了,因为以前的算法中,咱们都是采用第一个元素做为基准元素,那么必然会出现每次分区都会有一个分区为空。这种状况固然须要避免。

一种很容易的解决方法是不要选取固定位置的元素做为基准元素,而是随机从数组里挑出一个元素做为基准元素。这个方法颇有效,极大几率地避免了最差状况。这种处理思想很简单,我就不另外写代码了。

然而极大几率地避免最差状况并不等于避免最差状况,特别是对于数组很大的时候,更要求咱们在选取基准元素的时候要更谨慎些。

三数取中(median-of-three)

基准元素应当精心挑选,而挑选基准元素的一种方法为三数取中,即挑选基准元素时,先把第一个元素、最后一个元素和中间一个元素挑出来,这三个元素中大小在中间的那个元素就被认为是基准元素。

简单实现一下获取基准元素的方法:

function getPivot(arr, func, from, to) {
    var middle = (from + to) >> 1;
    var i0 = arr[from];
    var i1 = arr[to];
    var i2 = arr[middle];
    var temp;
    if (func(i0, i1) > 0) {
        temp = i0;
        i0 = i1;
        i1 = temp;
    }
    if (func(i0, i2) > 0) {
        arr[middle] = i0;
        arr[from] = i2;
        arr[to] = i1;
        return i0;
    } else {
        arr[from] = i0;
        if (func(i1, i2) > 0) {
            arr[middle] = i1;
            arr[to] = i2;
            return i1;
        } else {
            arr[middle] = i2;
            arr[to] = i1;
            return i2;
        }
    }
}
复制代码

这个例子里我彻底没管基准元素的位置,一是下降复杂度,另外一个缘由是下面讨论重复元素处理时,基准元素的位置没什么意义。不过我把最小的值赋给了第一个元素,最大的值赋给了第二个元素,后面处理重复元素时会有帮助。

固然,仅仅是三数取中得到的基准元素,也不见得是可靠的。因而有一些其余的取中值的方法出现。有几种比较典型的手段,一种是平均间隔取一个元素,多个元素取中位数(即多取几个,增长可靠性);一种是对三数取中进行递归运算,先把大数组平均分红三块,对每一块进行三数取中,会获得三个中值,再对这三个中值取中位数。

不过查阅v8的源代码,发现v8的基准元素选取更为复杂。若是数组长度不超过1000,则进行基本的三数取中;若是数组长度超过1000,那么v8的处理是除去首尾的元素,对剩下的元素每隔200左右(200~215,并不固定)挑出一个元素。对这些元素排序,找出中间的那个,并用这个元素跟原数组首尾两个元素一块儿进行三数取中。这段代码我就不写了。

针对重复元素的处理

到目前为止,咱们在处理元素比较的时候比较随意,并无太多地考虑元素相等的问题。但实际上咱们作了这么多性能优化,对于重复元素引发的性能问题并无涉及到。重复元素会带来什么问题呢?设想一下,一个数组里若是全部元素都相等,基准元素无论怎么选都是同样的。那么在分区的时候,必然出现除基准元素外的其余元素都被分到一块儿去了,进入最差性能的case。

那么对于重复元素应该怎么处理呢?从性能的角度,若是发现一个元素与基准元素相同,那么它应该被记录下来,避免后续再进行没必要要的比较。因此仍是得改分区的代码。

function QuickSortWithPartitionDump(arr, func, from, to) {
    if (!arr || !arr.length) return [];
    from = from === void 0 ? 0 : from;
    to = to === void 0 ? arr.length - 1 : to;
    if (from >= to - 1) return arr;
    var pivot = getPivot(arr, func, from, to);
    var smallEnd = from;
    var bigBegin = to;
    for (var i = smallEnd + 1; i < bigBegin; i++) {
        var order = func(arr[i], pivot);
        if (order < 0) {
            smallEnd++;
            swap(arr, i, smallEnd);
        } else if (order > 0) {
            while (bigBegin > i && order > 0) {
                bigBegin--;
                order = func(arr[bigBegin], pivot);
            }
            if (bigBegin == i) break;
            swap(arr, i, bigBegin);
            if (order < 0) {
                swap(arr, i, smallEnd);
                smallEnd++;
            }
        }
    }
    QuickSortWithPartitionDump(arr, func, from, smallEnd);
    QuickSortWithPartitionDump(arr, func, bigBegin, to);
    return arr;
}
复制代码

简单解释一下这段代码,上文已经说过,在 getPivot方法中,我将比基准小的元素放到第一位,把比基准大的元素放到最后一位。定义三个变量 smallEnd、 bigBegin、 i,从 from到smallEnd之间的元素都比基准元素小,从 smallEnd到 i之间的元素都和基准元素同样大,从i到 bigBegin之间的元素都是尚未比较的,从 bigBegin到 to之间的元素都比基准元素大。了解这个关系就好理解这段代码了。遍历从 smallEnd + 1到 bigBegin之间的元素:

  • 若是这个元素小于基准,那么 smallEnd增长1,这时 smallEnd位置的元素是等于基准元素的(或者此时 smallEnd与 i相等),交换 smallEnd与 i处的元素就能够了。
  • 果这个元素大于基准,相对比较复杂一点。此时让 bigBegin减少1,检查大数分区前面一个元素是否是大于基准,若是大于基准,重复此步骤,不断让 bigBegin减少1,直到找到不比基准大的元素(若是这个过程当中,发现 bigBegin与 i相等,则停止遍历,说明分区结束)。找到这个不比基准大小元素时须要区分是否是比基准小。若是比基准小,须要作两步交换,先将i位置的大数和 bigBegin位置的小数交换,这时跟第一种case同时, smallEnd增长1,而且将 i位置的小数和 smallEnd位置的元素交换。若是和基准相等,则只须要将 i位置的大数和 bigBegin位置的小数交换。
  • 果这个元素与基准相等,什么也不用作。

小数组优化

对于小数组(小于16项或10项。v8认为10项如下的是小数组。),可能使用快速排序的速度还不如平均复杂度更高的选择排序。因此对于小数组,可使用选择排序法要提升性能,减小递归深度。

function insertionSort(a, func, from, to) {
    for (var i = from + 1; i < to; i++) {
        var element = a[i];
        for (var j = i - 1; j >= from; j--) {
            var tmp = a[j];
            if (func(tmp, element) > 0) {
                a[j + 1] = tmp;
            } else {
                break;
            }
        }
        a[j + 1] = element;
    }
}
复制代码

v8引擎没有作的优化

因为快速排序的不稳定性(少数状况下性能差,前文已经详细描述过),David Musser于1997设计了内省排序法(Introsort)。这个算法在快速排序的基础上,监控递归的深度。一旦长度为n的数组通过了logn层递归(快速排序算法最佳状况下的递归层数)尚未结束的话,就认为此次快速排序的效率可能不理想,转而将剩余部分换用其余排序算法,一般使用堆排序算法(Heapsort,最差时间复杂度和最优时间复杂度均为O(logn))。

v8引擎额外作的优化

快速排序递归很深,若是递归太深的话,很能够出现“爆栈”,咱们应该尽量避免这种状况。上面提到的对小数组采用选择排序算法,以及采用内省排序算法均可以减小递归深度。不过v8引擎中,作了一些不太常见的优化,每次咱们分区后,v8引擎会选择元素少的分区进行递归,而将元素多的分区直接经过循环处理,无疑这样的处理大大减少了递归深度。我大体把v8这种处理的过程写一下:

function quickSort(arr, from, to){
    while(true){
        // 排序分区过程省略
        // ...
 
        if (to - bigBegin < smallEnd - from) {
            quickSort(a, bigBegin, to);
            to = smallEnd;
        } else {
            quickSort(a, from, smallEnd);
            from = bigBegin;
        }
    }
}
复制代码

不得不说是一个很巧妙的实现。

总结

不知不觉这篇文章写了这么长。原本想对比各类优化之间的性能差别,如今看来也没有什么必要。虽然快速排序算法是一个很容易很基础的算法,但我相信不少人并无可以这么深刻地去了解、去优化一个算法。而读过了v8引擎对于这么一个简单算法的实现后,我发现它并无简单地为了实现一个算法而去实现,而是确确实实地尽一切可能去提升算法效率,去消除可能引发性能问题的因素。结论是你真的能够放心地使用 Array.sort方法,它的性能使人放心。那么剩下问题的就是:做为开发者,咱们应该如何编写高质量高性能的代码?是否是应该更精益求精一点,让咱们代码更经得起推敲,更值得信任?

相关文章
相关标签/搜索