基础排序算法总结【JavaScript实现】

1,377 阅读6分钟

如何分析一个排序算法

  • 排序算法的执行效率:最好情况、最坏情况、平均情况的时间复杂度

  • 排序算法的内存消耗:空间复杂度

  • 排序算法的稳定性:如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变,则称该排序算法稳定

稳定排序的好处:

在真正的软件开发中,我们要排序的往往不是单纯的整数,而是一组对象,然后按照对象的某个 key 来排序。如果我们的需求是按 key1 进行排序,当 key1 的值相同时,按 key2 进行排序,不使用稳定排序的解决方案是先对 key1 进行排序,然后遍历排序之后的值,对每个 key1 值相同的小区间再进行 key2 排序,实现起来会比较复杂。

使用稳定排序的方案:先按照 key2 进行排序,排序完成后,使用稳定排序算法按照 key1 重新排序。

试想一下对一组学生按年龄进行排序,在年龄相等的情况下按照身高进行排序。

以下是在 leetcode 上测的各个排序的时间和空间消耗,可以看出快排无论是在时间还是空间上都比较优秀,归并算法要注意优化,直接全部递归很容易时间复杂度爆表。

冒泡排序(熟悉)

  • 基本思想:使用两层循环,外层循环每一次经过两两比较,把每一轮未排定部分最大的元素放到了数组的末尾;
  • 优化:当某次冒泡操作已经没有数据交换时,说明已经达到完全有序,不用再继续执行后续的冒泡操作。第一层循环里设置一个提前退出冒泡循环的标志位,在第二层循环里,当有数据交换时改变它的值,如果经过一次冒泡没有改变过 flag 的值,则 break
var sortArray = function(nums) {
    const len = nums.length;
    // 从后往前遍历,排过序的就不用再走一遍了
    for(let i=len-1; i>=0; i--){
        let flag = true;
        for(let j=0; j<i; j++){
            if(nums[j]>nums[j+1]){
                const temp = nums[j+1];
                nums[j+1] = nums[j];
                nums[j] = temp;
                flag = false;
            }           
        }
        if(flag) break;
    }
    return nums;
};

分析:

  • 空间复杂度:O(1) ,只涉及相邻数据的交换操作,只需要常量级的临时空间,是一个原地排序。

  • 稳定性:稳定。当两个相邻的元素大小相等时,不做交换,排序后,其相对位置不变。

  • 时间复杂度:

最好情况 :完全有序,一次冒泡,时间复杂度 O(n)
最坏情况:完全逆序,n 次冒泡,时间复杂度 O(n2)
平均情况:n*(n-1)/4 = O(n2)

插入排序(熟悉)

  • 思路:每次将一个数字插入一个有序的数组里,成为一个长度更长的有序数组,有限次操作以后,数组整体有序。初始已排序区间只有一个元素,就是数组的第一个元素。
var sortArray = function(nums) {
    const len = nums.length;
    // 循环不变量:将 nums[i] 插入到区间 [0, i) 使之成为有序数组
    for(let i=1; i<len; i++){
        // 先暂存这个元素,然后之前元素逐个后移,留出空位
        const currentVal = nums[i];
        let j = i;
        // 注意边界 j > 0
        while(j>0 && nums[j-1] > currentVal ){
            nums[j] = nums[j-1];
            j--;
        }
        nums[j] = currentVal;
    }
    return nums;
};

分析:

  • 时间复杂度:
    最好情况:在数组「几乎有序」的时,插入排序的时间复杂度可以达到 O(n); 最坏/平均情况:O(n2)
  • 空间复杂度:O(1) 使用到常数个临时变量,是原地排序
  • 稳定性:稳定。对于值相同的元素,我们可以选择将后面出现的元素,插入到前面出现元素的后面,保持原有的前后顺序不变。
  • 「插入排序」在「几乎有序」的数组上表现良好,特别地,在「短数组」上的表现也很好。因为「短数组」的特点是:每个元素离它最终排定的位置都不会太远。为此,在小区间内执行排序任务的时候,可以转向使用「插入排序」。

选择排序(了解)

  • 思路:每一轮选取未排定的部分中最小的元素交换到已排定部分的末尾(未排定部分的开头),经过若干个步骤,就能排定整个数组。即:先选出最小的,再选出第 2 小的,以此类推。
var sortArray = function(nums) {
    const len = nums.length;
    // [0, i) 有序,且该区间里所有元素就是最终排定的样子
    for(let i=0; i<len-1; i++){
        let minIndex = i;
        // 从未排序区间找出最小值, 交换到下标i
        for(let j=i+1; j<len; j++){
            if(nums[j]<nums[minIndex]){
                minIndex = j;
            }
        }
        swap(nums, i, minIndex);
    }
    return nums;

    function swap(nums, index1, index2){
        const temp = nums[index2];
        nums[index2] = nums[index1];
        nums[index1] = temp;
    }
};

使用到的算法思想:

  • 贪心算法:每一次决策只看当前,当前最优,则全局最优。注意:这种思想不是任何时候都适用。

  • 减治思想:外层循环每一次都能排定一个元素,问题的规模逐渐减少,直到全部解决,即「大而化小,小而化了」。运用「减治思想」很典型的算法就是大名鼎鼎的「二分查找」。

分析:

  • 时间复杂度:最好,最坏,平均情况的时间复杂度都是 O(n2)
  • 空间复杂度:O(1) 原地排序
  • 稳定性:不稳定。因为在交换的时候,前面的值可能会被交换到后面。
  • 相比于冒泡和插入,选择排序稍显逊色了。不太常用。但它的交换次数最少,如果在交换成本较高的排序任务中,可以考虑使用「选择排序」。

归并排序(重点)

  • 思路: 先把数组从中间分为前后两部分,对前后两部分分别排序,然后借助额外空间,合并两个有序数组,得到更长的有序数组。
  • 算法思想:分治思想,分而治之,将大问题分解成小的子问题来解决,小的子问题解决了,大问题也就解决了。分治算法一般都是用递归来实现的,分治是一种解决问题的处理思想,递归是一种编程技巧,这两者并不冲突。
  • 「归并排序」是理解「递归思想」的非常好的学习材料,大家可以通过理解:递归完成以后,合并两个有序数组的这一步骤,想清楚程序的执行流程。即「递归函数执行完成以后,我们还可以做点事情」。
var sortArray = function(nums) {

    mergeSort(nums, 0, nums.length-1);
    return nums;
    
    // 对数组 nums 的子区间 [left, right] 进行归并排序
    // temp: 用于合并两个有序数组的辅助数组,全局使用一份,避免多次创建和销毁
    function mergeSort(nums, left, right, temp = []){
        if(left>=right) return;  //递归终止条件
        
        // let mid = left + right >>> 1;
        let mid = left + Math.floor((right-left) / 2);

        // 递归左边和右边, 使左右两边分别有序
        mergeSort(nums, left, mid, temp);
        mergeSort(nums, mid+1, right, temp);

        // 左右两边有序之后,对它们进行合并
        mergeTwoSortedArray(nums, left, mid, right, temp);
    }

    // 合并两个有序数组:先把值复制到临时数组,再合并回去
    function mergeTwoSortedArray(nums, left, mid, right, temp){
        temp = [...nums];

        let i = left;
        let j = mid+1;
        for(let k=left; k<=right; k++){
            if(i === mid+1){ //i走到了中间,说明左边已经放完了,之后只需循环把右边放进去
                nums[k] = temp[j];
                j++;
            }else if(j === right+1){ //j走到了最后,说明右边已经放完了,之后只需循环把左边放进去
                nums[k] = temp[i];
                i++;
            }else if(temp[i] <= temp[j]){ // 比较i,j位置的元素,谁小放谁进去,并让其指针右移
               // 注意写成 < 就丢失了稳定性(相同元素原来靠前的排序以后依然靠前)
                nums[k] = temp[i];
                i++;
            }else{
                nums[k] = temp[j];
                j++;
            }
        }
    }  
};

优化:

  • 在「小区间」里转向使用「插入排序」,参考JavaScript数组的sort排序
  • 在「两个数组」本身就是有序的情况下,无需合并;
  • 注意:实现归并排序的时候,要特别注意,不要把这个算法实现成非稳定排序,区别就在 <= 和 < ,已在代码中注明。
// 优化后代码:
var sortArray = function(nums) {
    const INSERTION_SORT_THRESHOLD = 7;

    mergeSort(nums, 0, nums.length-1);
    return nums;
    
    // 对数组 nums 的子区间 [left, right] 进行归并排序
    function mergeSort(nums, left, right, temp = []){
        // 小区间使用插入排序
        if(right - left <= INSERTION_SORT_THRESHOLD){
            insertSort(nums, left, right);
            return;
        }

        // let mid = left + right >>> 1;
        let mid = left + Math.floor((right-left) / 2);

        // 递归左边和右边, 使左右两边分别有序
        mergeSort(nums, left, mid, temp);
        mergeSort(nums, mid+1, right, temp);
        
        // 如果数组的这个子区间本身有序,无需合并
        if (nums[mid] <= nums[mid + 1]) {
            return;
        }

        mergeTwoSortedArray(nums, left, mid, right, temp);
    }

    // 对数组 nums 的子区间 [left, right] 使用插入排序
    function insertSort(nums, left, right){
        for(let i=left+1; i<=right; i++){
            const currentVal = nums[i];
            let j = i;
            while(j>left && nums[j-1] > currentVal ){
                nums[j] = nums[j-1];
                j--;
            }
            nums[j] = currentVal;
        }
        return nums;
    }

    // 合并两个有序数组:先把值复制到临时数组,再合并回去
    function mergeTwoSortedArray(nums, left, mid, right, temp){
        temp = [...nums];

        let i = left;
        let j = mid+1;
        for(let k=left; k<=right; k++){
            if(i === mid+1){ //i走到了中间,说明左边已经放完了,之后只需循环把右边放进去
                nums[k] = temp[j];
                j++;
            }else if(j === right+1){ //j走到了最后,说明右边已经放完了,之后只需循环把左边放进去
                nums[k] = temp[i];
                i++;
            }else if(temp[i] <= temp[j]){ // 比较i,j位置的元素,谁小放谁进去,并让其指针右移
               // 注意写成 < 就丢失了稳定性(相同元素原来靠前的排序以后依然靠前)
                nums[k] = temp[i];
                i++;
            }else{
                nums[k] = temp[j];
                j++;
            }
        }
    }  
};

分析:

  • 时间复杂度:O(nlogn)
  • 空间复杂度:O(n),不是原地排序,因为合并的时候需要额外的空间,辅助数组与输入数组规模相当。
  • 稳定性:稳定。

快速排序(重点)

  • 思路:快速排序每一次通过分区排定一个元素(基准值),这个元素呆在了它最终应该呆的位置,然后递归地去排它左边的部分和右边的部分,依次进行下去,直到数组有序;
  • 算法思想:分而治之(分治思想),与「归并排序」不同,「快速排序」在「分」这件事情上不像「归并排序」无脑地一分为二,而是采用了 partition(分区) 的方法,因此就没有「合」的过程。可以发现,归并排序的处理过程是由下到上的,先处理⼦问题,然后再合并。⽽快排正好相反,它的处理过程是由上到 下的,先分区,然后再处理⼦问题。

如果我们不考虑空间消耗的话,partition() 分区函数可以写得⾮常简单。申请两个临时数组 XY ,遍历给定数组 nums ,将⼩于 pivot 的元素都拷⻉到临时数组 X ,将⼤于 pivot 的元素都拷⻉到临时数组 Y,最后再将数组 X 和数组 Y 中数据顺序拷⻉到 nums 中。具体写法可以参考:阮一峰老师的快排实现

但是,如果按照这种思路实现的话,partition() 函数就需要很多额外的内存空间,所以快排就不是原地排序算法了。如果 我们希望快排是原地排序算法,那它的空间复杂度得是 O(1),那 partition() 分区函数就不能占⽤太多额外的内存空间,我们就需要在 nums 数组的原地完成分区操作。

使用 单指针 & 交换 完成分区操作的快排:

var sortArray = function(nums) {

    quickSort(nums, 0, nums.length-1)
    return nums;

    function quickSort(nums, left, right){
        if(left>=right) return;

        // 将nums分区,使得nums[pIndex]左边的都小于nums[pIndex], 右边的都大于nums[pIndex]
        let pIndex = partition(nums, left, right);

        // 对基准值左右两边递归的分区(排序)
        quickSort(nums, left, pIndex-1);
        quickSort(nums, pIndex+1, right);

    }

    // 分区函数
    function partition(nums, left, right){
        // 取中间的值作为基准值
        let mid = left + Math.floor((right-left) / 2);
        swap(nums, left, mid); // 把基准值交换到第一项

        // 基准值
        let pivot = nums[left];
        let lt = left; // 开拓小于基准值的区间的指针
        // 循环不变量:
        // all in [left + 1, lt] < pivot
        // all in [lt + 1, i) >= pivot
        for(let i=left+1; i<=right; i++){
            if(nums[i]<pivot){
                lt++;
                swap(nums, i, lt) 
            }
        }
        swap(nums, left, lt); //将基准值换到中间
        return lt;
    }

    function swap(nums, index1, index2){
        const temp = nums[index2];
        nums[index2] = nums[index1];
        nums[index1] = temp;
    }
};

注意事项:

  • 针对特殊测试用例:顺序数组或者逆序数组。一定要随机化选择切分元素(pivot),否则在输入数组是有序数组或者是逆序数组的时候,如果 pivot 选的是 left 或者 right ,快速排序会变得非常慢(等同于冒泡排序或者选择排序), 时间复杂度退化到O(n2)
  • 针对特殊测试用例:有很多重复元素的输入数组,有 3 种版本的解法:
    • 版本 1:基本解法,就是上述单指针解法,把等于切分元素的所有元素分到了数组的同一侧,可能会造成递归树倾斜;
    • 版本 2:双指针解法:把等于切分元素的所有元素等概率地分到了数组的两侧,避免了递归树倾斜,递归树相对平衡;
    • 版本 3: 三指针解法:把等于切分元素的所有元素挤到了数组的中间,在有很多元素和切分元素相等的情况下,递归区间大大减少。

之所以解法有这些优化,起因都是来自「递归树」的高度。关于「树」的算法的优化,绝大部分都是在和树的「高度」较劲。类似的通过减少树高度、使得树更平衡的数据结构还有「二叉搜索树」优化成「AVL 树」或者「红黑树」、「并查集」的「按秩合并」与「路径压缩」。

分析:

  • 时间复杂度:O(nlogn)
  • 空间复杂度:O(1)
  • 稳定性:不稳定。因为交换时可能会打乱相等元素的原有顺序。

JavaScript数组原生sort排序

问题:JavaScript 数组的原生 sort 排序稳定吗?

不同浏览器的js引擎对sort的实现方式不一样,这里以 chrome V8 来说,

V8 引擎的 sort 实现:对于长度 <= 10 的数组使用 插入排序,比 10 大的数组则使用 原地快速排序。所以当数组长度小于 10 时是稳定的,而当数组长度大于 10 时,sort 是不稳定的。

V8 源码 (710行开始)