超级长文,全是含金量!!十大经典排序算法分析js版----轻松掌握原理及实现方式!!!

前言

在开发过程中,有大量场景需要用到排序,选对排序算法,可以大幅度的提升代码性能,让应用更加流畅。
下面是几个经典的排序算法,结合我个人开发使用的理解,进行一波深入浅出的分析。
注意:文中均以升序为例。

一、冒泡排序

步骤

1、从左依次将相邻的两个元素进行比较,若左边比右边大,则互换位置。 一轮结束后,最大的元素就出现在了最右边。
2、将剩下的元素重复步骤1,直到没有元素可以进行比较。

示意图

在这里插入图片描述

流程图

在这里插入图片描述

代码实现

    function bubbleSort(arr) {
        let len = arr.length; //先将数组长度保存,防止每一次循环都需要重新计算数组长度,用空间换时间
        for(let i = 0;i < len - 1;i++) { //剩最后一个数时已经排序完毕,所以只需要进行length-1次冒泡
            for(let j = 0;j < len - i - 1;j++) {//后i位是已经冒泡完毕的有序数组,不需要再比较,所以到length-i-1就能完成一次冒泡
                if(arr[j] > arr [j+1]) { //如果左边比右边大,则需要交换位置
                    let temp = arr[j+1];
                    arr[j+1] = arr[j];
                    arr[j] = temp;
                }
            }
        }
    }

算法分析

冒泡排序需要进行n-1趟排序,每趟排序要进行n-i次比较(1≤i≤n-1)。所以时间复杂度为O(n²)。
交换时需要一个额外容器,所以空间复杂度为O(1).

算法稳定性分析

因为冒泡排序的交换总是发生在相邻元素,所以相等的两个元素是不会发生交换的,所以冒泡排序算法是稳定的。

优化方案

思考

如果数据的顺序提前排好之后,冒泡算法仍然会继续进行下一轮的比较,直到arr.length-1次,后面的比较完全没有意义。
设置一个flag标记,如果发生了交换则将flag设置为true,如果一轮冒泡完没有发生交换(flag值为false),则表示排序已经完成,不用再进行下一轮冒泡。

流程图

在这里插入图片描述

代码实现
    function bubbleSort2(arr) {
        let len = arr.length;
        let flag;
        for(let i = 0;i < len - 1;i++) {
            //每一轮冒泡开始时都需要初始化一次flag
            flag = false;
            for(let j = 0;j < len - i - 1;j++) {
                if(arr[j] > arr [j+1]) {
                    let temp = arr[j+1];
                    arr[j+1] = arr[j];
                    arr[j] = temp;
                    flag = true;
                }
            }
            //如果没有发生交换(falg值未变为true),则证明数组已经有序,不用再进行冒泡
            if(!flag) {
                break;
            }
        }
    }

如上,最好情况,数组本身已经是正序的情况下,只需要比较一趟即可完成排序。时间复杂度为:O(n)。性能大幅提升。

对比验证

在这里插入图片描述
10000条有序数据样本排序对比。
在这里插入图片描述
前5000条无序,后5000条有序数据样本排序对比。
可以看出来,数据越趋近于有序,性能提升越明显。

总结

优点:
实现简单、易于理解;
对于已经有序的数列,它的时间复杂度是O(n),非常高效,在处理小规模数据时非常实用。
缺点:
时间复杂度较高,即冒泡排序的最坏时间复杂度为O(n^2),当输入规模较大时,算法执行时间会明显增加;
冒泡排序只能对相邻的元素进行比较,因此无法利用输入数列的其他性质来优化,相对于一些排序算法,其排序效率较低。
所以,冒泡排序只适用于对于数据量较小的数列进行排序,对于数据量较大的数列排序,建议使用其他更为高效的排序算法。

二、插入排序

步骤

1、将第一个数当成一个有序部分。
2、将第二个数插入已经形成有序部分,形成新的有序部分。
3、对后面的每个元素执行步骤2,产生的结果即为一个有序数组。

示意图

在这里插入图片描述

流程图

在这里插入图片描述

代码实现

    function insertSort(arr) {
        let len = arr.length; //先将数组长度保存,防止每一次循环都需要重新计算数组长度,空间换时间
        for(let i = 1;i < len;i++) { //第一个元素默认作为有序部分,从第二个元素开始插入
            if(arr[i] < arr[i-1]) { //只有发生逆序时,才需要进行插入
                let temp = arr[i];
                //将前面元素依次向后移动一位,直到找到插入位置
                for ( var j = i-1;j >= 0 && arr[j] > temp;j--) {
                    arr[j+1] = arr[j]; //元素依次后移
                }
                arr[j+1] = temp; //将待插入元素放入目标位置
            }
        }
    }

算法分析

如果数据正序,只需要走一趟即可完成排序。插入最佳的时间复杂度为O(n)。
如果数据反序,则需要进行n-1趟排序。每趟排序要进行i次比较(1≤i≤n-1),所以时间复杂度为O(n²)。
交换时需要一个额外容器,所以空间复杂度为O(1)。

算法稳定性分析

因为插入排序是从前往后插入,且插入时往后移动的总是比他大的元素,所以相等的两个元素的前后位置是不会发生改变的,所以插入排序算法是稳定的。

优化方案

思考

因为插入排序已经完成的部分是有序的,所以在查找插入位置时,使用二分查找,效率会更高。
与直接遍历插入相比,移动次数不变,但是查找比较次数变少。

流程图

在这里插入图片描述

代码实现
   function insertSort2(arr) {
        let len = arr.length; //先将数组长度保存,防止每一次循环都需要重新计算数组长度,空间换时间
        for(let i = 1;i < len;i++) { //第一个元素默认作为有序部分,从第二个元素开始插入
            if(arr[i] < arr[i-1]) { //只有发生逆序时,才需要进行插入
                let temp = arr[i];
                let low = 0;
                let high = i-1;
                let mid ;
                while (low<=high) { //折半查找插入位置
                    mid = Math.floor((low+high)/2);
                    if(temp<arr[mid]) {
                        high = mid - 1;
                    }else {
                        low = mid + 1;
                    }
                }
                for ( var j = i;j > low ;j--) { //将low及之后位置的元素依次后移
                    arr[j] = arr[j-1];
                }
                arr[low] = temp; //将待插入元素放入目标位置
            }
        }
    }

与直接插入排序相比,二分法折半插入的时间复杂度为O(nlogn),显然效率更高。

对比验证

在这里插入图片描述
对同一份数据使用插入排序及折半插入排序,多个10000条随机数据样本测试下,折半比插入快3-4ms。

总结

与冒泡排序类似,插入排序也实现简单,易于理解,对趋于有序的数据,排序效率较高,适合对小型数据集进行排序。
但是时间复杂度较高,数据量大时效率低。
所以,插入排序同样只适用于对于数据量较小的数列进行排序,对于数据量较大的数列排序,建议使用其他更为高效的排序算法。

三、选择排序

步骤

1、为数组第一位在所有元素中选出一个最小的元素。
2、为数组第二位在剩下的元素中选出一个最小的元素。
3、重复步骤2,依次选出最小的元素。

示意图

在这里插入图片描述

流程图

在这里插入图片描述

代码实现

    function selectSort(arr) {
        let len = arr.length;//先将数组长度保存,防止每一次循环都需要重新计算数组长度,空间换时间
        for(let i = 0 ;i < len-1;i++) { //为数组每一位依次选择最小值
            let min = i;
            for(let j = i+1;j < len;j++) { //为第i位选出最小值
                if(arr[min] > arr[j]) { //如果比当前最小的小,更新
                    min = j;
                }
            }
            if(min !== i) { //如果最小值不是当前位置,互换位置
                let temp = arr[min];
                arr[min] = arr[i];
                arr[i] = temp;
            }
        }
    }

算法分析

时间复杂度为O(n²), 交换次数的时间复杂度为O(n),比冒泡排序的O(n²)少了很多,所以在n较小时,选择排序比冒泡排序效率高。
交换时需要一个额外容器,所以空间复杂度为O(1)。

算法稳定性分析

当有相等的元素时,选择排序可能会改变他们的相对位置。例如:[1,7,5,7,2]使用选择排序时,会先将第一个7和2交换位置,这样两个7的相对位置就发生了改变。所以选择排序算法不稳定。

总结

在数据规模较小且无序时,选择排序比冒泡和插入排序移动次数要少,效率更高。但是,不管是最好还是最坏情况,选择排序的时间复杂度都为O(n²),效率很低,数据量大时尤其不实用。而且,选择排序可能会交换相等元素的位置,是不稳定的算法。
所以,并不推荐使用选择排序算法。

四、快速排序

步骤

1、选取一个基准元素,将比基准元素小的放在左边,比基准元素大的放在右边。
2、将左右两部分进行递归执行步骤1。

示意图

在这里插入图片描述

流程图

在这里插入图片描述

代码实现

    function quickSort(arr) {
        const sort = (arr,left = 0,right = arr.length-1) =>{
            if(left>=right) { //起始值大于等于终止值,说明待排序只有一个元素,完成递归
                return;
            }
            let i = left;
            let j = right;
            const baseVal = arr[i]; //使用左边第一个元素作为基准值
            while (i<j) {
                //先从右往左找,找到比基准值小的放在空出来的最左边位置。
                while (j>i && arr[j]>=baseVal){ //直到找到比基准元素小的或找到最左边
                    j--;
                }
                arr[i] = arr[j];//将找到的值放在左边空位,没有找到时j=i,相当于自己赋值给自己
                //从左往右找,找到比基准值大的放入上一步空出来的位置
                while(i<j && arr[i]<=baseVal) {
                    i++;
                }
                arr[j] = arr[i];
            }
            //当i=j时,左边元素都比基准值小,右边元素都比基准值大,将基准值放入空出来的位置
            //这时候基准值左边整体相对于基准值右边整体是有序的
            arr[j] = baseVal;
            sort(arr,left,j-1); //将左边当成一个新的无序部分递归执行
            sort(arr,j+1,right);//将右边当成一个新的无序部分递归执行
        }
        sort(arr);
    }

算法分析

1.进行一次划分需要分别从两头交替搜索,直到left和right重叠,所以一次划分的时间复杂度为O(n);
2.最好的情况下,每次划分几乎将待排序部分等分,时间复杂度为O(nlogn);
3.最坏情况下,每次选取基准值都为最大或最小值,使得其中一边为空序列,这样需要经过n次才可完成,这时整个时间复杂度为O(n²);
快速排序的平均时间复杂度为O(nlogn)。
每次递归都需要一个额外空间来交换,所以空间复杂度为O(nlogn)。

算法稳定性分析

在快速排序过程中,当从前后两端交替搜索,替换元素位置时,两个相等元素的相对位置可能会发生变化,所以快速排序是不稳定的排序算法。

优化方案

思考

快排划分时,最好情况下时间复杂度为O(nlon2n),而最坏情况下时间复杂度为O(n^2),差距较大,需要尽可能避免最坏情况发生。保证每次划分尽量等分是关键。
所以,修改每次划分选取基准值的方案,不再直接选取头或尾,而是比较头,尾以及中间三个位置的元素大小,取他们三个的中间值为基准元素。

流程图

在这里插入图片描述

代码实现
    function quickSort2(arr) {
        const sort = (arr,left = 0,right = arr.length-1) =>{
            if(left>=right) { //起始值大于等于终止值,说明待排序只有一个元素,完成递归
                return;
            }
            let i = left;
            let j = right;
            let mid = Math.floor((i+j)/2);
            //取arr[i],arr[j],arr[mid]三种的中间值
            //开始我以为中间值可以这么取 后面发现不知道具体使用的哪一个作为基准没办法继续后面的步骤
            //const baseVal = arr[i]>arr[j]?(arr[i]<arr[mid]?arr[i]:(arr[j]>arr[mid]?arr[j]:arr[mid])):(arr[j]<arr[mid]?arr[j]:(arr[i]>arr[mid]?arr[i]:arr[mid]));
            //老老实实写if
            let baseVal;
            if((arr[j] < arr[i] && arr[i] < arr[mid])||(arr[j] > arr[i] && arr[i] > arr[mid])){ // i作为基准
                baseVal = arr[i];
                while (i<j) {
                    //需要先从右往左找,找到一个比基准值小的来填空出来的最左边位置。
                    //从右往左找
                    while (j>i && arr[j]>=baseVal){ //直到找到比基准元素小的或找到最左边
                        j--;
                    }
                    arr[i] = arr[j];//将找到的值放在左边空位,没有找到时j=i,相当于自己赋值给自己
                    //从左往右找,找到比基准值大的放入上一步空出来的位置
                    while(i<j && arr[i]<=baseVal) {
                        i++;
                    }
                    arr[j] = arr[i];
                }
            }else if((arr[i] < arr[j] && arr[j] < arr[mid])||(arr[i] > arr[j] && arr[j] > arr[mid])) { //j作为基准
                baseVal = arr[j];
                while (i<j) {
                    //需要先从左往右找,找到一个比基准值大的来填空出来的最右边位置。
                    //从左往右找
                    while(i<j && arr[i]<=baseVal) {//直到找到比基准元素大的或找到最右边
                        i++;
                    }
                    arr[j] = arr[i];
                    //从右往左找
                    while (j>i && arr[j]>=baseVal){ //直到找到比基准元素小的或找到最左边
                        j--;
                    }
                    arr[i] = arr[j];//将找到的值放在左边空位,没有找到时j=i,相当于自己赋值给自己
                }
            }
            else{ //mid为基准
                baseVal = arr[mid];
                while (i<j) {
                    //从左往右找
                    while(i<mid && arr[i]<=baseVal) {//第一次的时候从右最多只能找到mid位置,否则会出现把比基准大的函数往左扔的情况
                        i++;
                    }
                    arr[mid] = arr[i];
                    //从右往左找
                    while (j>i && arr[j]>=baseVal){ //直到找到比基准元素小的或找到最左边
                        j--;
                    }
                    arr[i] = arr[j];//将找到的值放在左边空位,没有找到时j=i,相当于自己赋值给自己
                    mid = j; // 第一次过后,讲j赋值给mid回到正常的移动过程
                }
            }

            //当i=j时,左边元素都比基准值小,右边元素都比基准值大,将基准值放入空出来的位置
            //这时候基准值左边整体相对于基准值右边整体是有序的
            arr[j] = baseVal;
            sort(arr,left,j-1); //将左边当成一个新的无序部分递归执行
            sort(arr,j+1,right);//将右边当成一个新的无序部分递归执行
        }
        sort(arr);
    }

从流程图和代码可以看出,逻辑复杂了很多,但效率得到了提高。

阮一峰老师的快排

先直接上代码

var quickSort3 = function(arr) {
 
  if (arr.length <= 1) { return arr; }
 
  var pivotIndex = Math.floor(arr.length / 2);
 
  var pivot = arr.splice(pivotIndex, 1)[0];
 
  var left = [];
 
  var right = [];
 
  for (var i = 0; i < arr.length; i++){
 
    if (arr[i] < pivot) {
 
      left.push(arr[i]);
 
    } else {
 
      right.push(arr[i]);
 
    }
 
  }
 
  return quickSort3(left).concat([pivot], quickSort3(right));
 
};

这种实现方式思路非常清晰,在划分的时候很好理解,比直接在数组中交换位置直观,不过需要另外分配很多新的数组存储空间。

对比验证

多次以相同的三份一万条乱序数组测试三种快排耗时(篇幅有限,截取其中三次结果)
在这里插入图片描述
在这里插入图片描述在这里插入图片描述

多次以相同的三份十万条乱序数组测试三种快排耗时(篇幅有限,截取其中三次结果)
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
改用三分相同的一百万条乱序数组测试三种快排耗时(篇幅有限,截取其中三次结果)
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

由上述结果可以看出:
在一万条数据时,三种排序方式效率区别不大。
在十万条数据时,阮一峰老师的快排效率劣势就出来了,另外两种根据数据样本不同互有快慢。
在一百万条数据时,优化后的快排终于发挥了的作用,效率大幅领先另外两种快排。
在应用中选择时可以根据数据量大小及数据特点选择合适的快排方案。

总结

优点:
快速排序算法是一种高效的排序算法(时间复杂度为O(nlogn)。
缺点:
算法使用了递归分治的思想,逻辑比文中前几种排序算法要复杂一些;
在交换过程中可能会导致相等元素的相对位置变化,是不稳定的。

五、堆排序

使用堆排序之前,先了解一个关键的概念:堆的本质是一颗完全二叉树。
在一个长度为length的数组中:
1、完全二叉树的第一个非叶子节点下标为Math.floor(length/2 - 1)。
2、如果父节点的下标为 n,那么他两个子节点的下标分别为2n+1和2n+2。

步骤

1、将待排序数组构建成一个大根堆。
2、将堆顶元素与堆底元素交换。
3、将堆的大小减一,再通过不断地调整堆,使其满足堆的性质。
4、重复2和3步骤,直到堆中只剩下一个元素。

示意图

在这里插入图片描述

流程图

在这里插入图片描述

代码实现


function heapSort(arr) {
    // 创建大根堆, 当前节点下标,数组长度
    const createBigRootHeap = (arr, i, length) => {
        let temp = arr[i]
        for(let j = 2*i+1; j < length; j=2*j+1) { // 从节点i开始,依次向下交换父节点和子节点
            if (j+1 < length && arr[j] < arr[j+1]) { // 判断左右节点谁更大
                j++ // 换了哪边需要继续向下更新哪边
            }
            if(arr[j] > temp) { // 如果子节点比父节点大 需要交换
                arr[i] = arr[j]
                arr[j] = temp
                i = j
            } else { // 未发生交换时不用再继续向下更新
                break
            }
        }
    }
    let length = arr.length
    for(let i = Math.floor(length / 2 - 1); i >= 0; i--) { // 遍历生成大根堆
        createBigRootHeap(arr, i, length) // 从第一个非叶子节点向上更新
    }
    for(let i = length - 1; i > 0; i--) {
        let temp = arr[i]
        arr[i] = arr[0]
        arr[0] = temp
        createBigRootHeap(arr, 0, i) // 第一次以后,从顶部开始更新
    } 
}

算法分析

堆排序的构建堆平均时间复杂度为O(n),因为我们从倒数第二层第一个非叶子节点开始,需要的时间是常数级别。
堆排序的每次向下调整的时间复杂度为logn,因为每次向下调整都要把一个节点和两个孩子节点进行比较交换。这样,一共需要进行n次调整,即O(nlogn)。
堆排序的交换时间复杂度为n,因为我们需要进行n-1次交换。
所以堆排序的时间复杂度为O(nlogn)。
只需要额外一个交换时的存储容器,所以堆排序的空间复杂度为O(1)。

算法稳定性分析

堆排序在排序过程中可能会改变相等元素的相对位置,是一个不稳定排序算法。

总结

优点:
1、性能稳定,堆排序的时间复杂度为O(nlogn)且不会受数据类型影响。
2、占用内存小,堆排序为原地排序算法,除了交换暂存容器,不需要额外的存储空间。
3、适用范围广,不用比较相邻两个元素,所以适用于各种数据类型排序。
缺点:
1、会改变相等元素的相对位置,不稳定。
2、虽然时间复杂度和快排一样,但是常数因子较大,在实际使用中可能比快排要慢。

六、归并排序

步骤

归并排序采用了分治的思想。
1、先将数组拆分成若干个长度为1的子数组。
2、将子数组两两合并形成新的有序数组。
3、重复步骤2直到合并为一个数组。

示意图

在这里插入图片描述

流程图

在这里插入图片描述

代码实现

function mergeSort(arr) {
    // 合并排序
    const sort = (arr, left, mid, right) => {
        let temp = [...arr] // 备份数组
        let i = left // 左边当前比较下标
        let j = mid+1 // 右边当前比较下标
        for(let k = left; k <= right; k++) { // 更新数组种从left到right部分
            // left到mid、mid+1到right已经相对有序 所以只需要两个分段做比较
            // 一开始我是这么写的,但是最后发现一个问题,如果左侧或右侧有一边先比较完了,另一边就会和范围外的进行比较
            // if(temp[i] > temp[j]) { // 如果左边元素比右边大,右边元素放入k位置
            //     arr[k] = temp[j] // 将右边元素放入k位置
            //     j++ // 接着比较右边下一位元素
            // } else {
            //     arr[k] = temp[i] // 将左边元素放入k位置
            //     i++ // 接着比较左边下一位元素
            // }
            // 修改逻辑后如下
            if (i>mid) { // 说明左边已经比完了,只需要将右边依次放入
                arr[k] = temp[j] // 放入k位置
                j++ // 右边下一位元素
            } else if (j > right) { // 说明右边已经比完了,只需要将左边依次放入
                arr[k] = temp[i] // 放入k位置
                i++ // 左边下一位元素
            } else if(temp[i] > temp[j]) { // 如果左边元素比右边大,右边元素放入k位置
                arr[k] = temp[j] // 将右边元素放入k位置
                j++ // 接着比较右边下一位元素
            } else {
                arr[k] = temp[i] // 将左边元素放入k位置
                i++ // 接着比较左边下一位元素
            }
        }
    }
    // 拆分
    const merge = (arr, left, right) => {
        if (left >= right) return // 数组长度为1 终止递归
        let mid = (left+right)>>>1 // 新学到的取中间值的办法 比Math.floor((left+right)/2) 方便很多
        merge(arr, left, mid) // 分别拆分左右部分
        merge(arr, mid+1, right) // 分别拆分左右部分
        if (arr[mid] > arr[mid+1]) { // 如果左边大于右边,则需要更新顺序
            sort(arr, left, mid, right)
        }
    }
    merge(arr, 0, arr.length-1)
}

算法分析

因为采用递归的方式实现,所以时间复杂度为O(nlogn)。
因为每次排序都需要拷贝一份数组,所以空间复杂度为O(n)。

算法稳定性分析

归并排序过程中,前面比后面大才会改变位置,相等元素的相对位置不会发生改变,所以归并排序是稳定的。

优化方案

思考

时间复杂度O(nlogn)已经是比较类排序算法的极限了,归并排序中的分治也很稳定,不会像快排那样有极端情况。所以换个方向,想办法优化一下归并排序较高的空间占用。
在每次递归排序时都会新生成一份当前数组的备份,占用较大内存,如果只开辟一片公共空间存放备份,内存占用会大幅减少。

流程图

在这里插入图片描述

代码实现
function mergeSort2(arr) {
    // 合并排序
    const sort = (arr, left, mid, right, temp) => {
        temp.splice(left, right-left+1, ...arr.slice(left,right+1)) // 更新缓存容器要排序部分
        let i = left // 左边当前比较下标
        let j = mid+1 // 右边当前比较下标
        for(let k = left; k <= right; k++) { // 更新数组种从left到right部分
            // left到mid、mid+1到right已经相对有序 所以只需要两个分段做比较
            // 一开始我是这么写的,但是最后发现一个问题,如果左侧或右侧有一边先比较完了,另一边就会和范围外的进行比较
            // if(temp[i] > temp[j]) { // 如果左边元素比右边大,右边元素放入k位置
            //     arr[k] = temp[j] // 将右边元素放入k位置
            //     j++ // 接着比较右边下一位元素
            // } else {
            //     arr[k] = temp[i] // 将左边元素放入k位置
            //     i++ // 接着比较左边下一位元素
            // }
            // 修改逻辑后如下
            if (i>mid) { // 说明左边已经比完了,只需要将右边依次放入
                arr[k] = temp[j] // 放入k位置
                j++ // 右边下一位元素
            } else if (j > right) { // 说明右边已经比完了,只需要将左边依次放入
                arr[k] = temp[i] // 放入k位置
                i++ // 左边下一位元素
            } else if(temp[i] > temp[j]) { // 如果左边元素比右边大,右边元素放入k位置
                arr[k] = temp[j] // 将右边元素放入k位置
                j++ // 接着比较右边下一位元素
            } else {
                arr[k] = temp[i] // 将左边元素放入k位置
                i++ // 接着比较左边下一位元素
            }
        }
    }
    // 拆分
    const merge = (arr, left, right, temp) => {
        if (left >= right) return // 数组长度为1 终止递归
        let mid = (left+right)>>>1 // 新学到的取中间值的办法 比Math.floor((left+right)/2) 方便很多
        merge(arr, left, mid, temp) // 分别拆分左右部分
        merge(arr, mid+1, right, temp) // 分别拆分左右部分
        if (arr[mid] > arr[mid+1]) { // 如果左边大于右边,则需要更新顺序
            sort(arr, left, mid, right, temp)
        }
    }
    // 生成一个公共备份
    let temp = [...arr]
    merge(arr, 0, arr.length-1, temp)
}

对比验证
多次以相同的两份一万条乱序数组测试两种实现方式耗时(篇幅有限,截取其中三次结果)
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

多次以相同的两份十万条乱序数组测试两种实现方式耗时(篇幅有限,截取其中三次结果)
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
十万条数据情况下已经产生巨大差距了,就不进行百万条数据测试了。
但是,优化的是空间,怎么导致时间差距如此大?我们常常说时间换空间,空间换时间,也就是说一般情况下,空间占用变少了,时间就会变多,时间变少了,空间就会变多。也就说上面测试的现象是明显不合理的。对比优化前后的代码,能出问题的只有
在这里插入图片描述
难道[…arr]解构赋值的问题?
改成
在这里插入图片描述
再一次测试
多次以相同的两份一万条乱序数组测试两种实现方式耗时(篇幅有限,截取其中三次结果)
在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
多次以相同的两份十万条乱序数组测试两种实现方式耗时(篇幅有限,截取其中三次结果)
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
多次以相同的两份一百万条乱序数组测试两种实现方式耗时(篇幅有限,截取其中三次结果)
在这里插入图片描述
结果在100万条数据时优化内存的排序出现了意外,栈溢出了! 数组拷贝到备份时,报错了。不过证实了[…arr]的赋值方式性能确实很差,100万条数据的归并排序,比new Array(arr)多出了15s左右,所以以后开发过程中如果数据量较大,慎用[…arr]。
数据量大了之后,temp.splice(left, right-left+1, …arr.slice(left,right+1))拷贝数组出现了报错,我猜测可能是…arr.slice(left,right+1)导致传入的参数过多,导致栈溢出了。使用Array.aplice作为数组插入方法,数据量太大是行不通的。所以得寻找一种新的拷贝思路。
感谢万能的gpt!!!
在这里插入图片描述
学习到了一个新的知识点,使用Uint8Array来作为备份容器,直接使用set来拷贝,完美解决问题。
再来一轮测试!!!
多次以相同的两份一万条乱序数组测试两种实现方式耗时(篇幅有限,截取其中三次结果)
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

多次以相同的两份十万条乱序数组测试两种实现方式耗时(篇幅有限,截取其中三次结果)
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

多次以相同的两份一百万条乱序数组测试两种实现方式耗时(篇幅有限,截取其中三次结果)
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
这才是我预期的结果。
可以发现,一万条数据时,因为第二种方案优化了空间,比第一种要慢一点。
十万条数据时,第一种因为需要占用过多的内存,性能开销较大,出现劣势。
一百万条数据时,劣势进一步放大,且时间波动变得很大。
所以选择哪个方案,还是需要根据具体场景来抉择。

最终代码

// 方案一
function mergeSort(arr) {
    // 合并排序
    const sort = (arr, left, mid, right) => {
        let temp = new Array(arr) // 备份数组
        // let temp = [...arr] // 备份数组
        let i = left // 左边当前比较下标
        let j = mid+1 // 右边当前比较下标
        for(let k = left; k <= right; k++) { // 更新数组种从left到right部分
            // left到mid、mid+1到right已经相对有序 所以只需要两个分段做比较
            // 一开始我是这么写的,但是最后发现一个问题,如果左侧或右侧有一边先比较完了,另一边就会和范围外的进行比较
            // if(temp[i] > temp[j]) { // 如果左边元素比右边大,右边元素放入k位置
            //     arr[k] = temp[j] // 将右边元素放入k位置
            //     j++ // 接着比较右边下一位元素
            // } else {
            //     arr[k] = temp[i] // 将左边元素放入k位置
            //     i++ // 接着比较左边下一位元素
            // }
            // 修改逻辑后如下
            if (i>mid) { // 说明左边已经比完了,只需要将右边依次放入
                arr[k] = temp[j] // 放入k位置
                j++ // 右边下一位元素
            } else if (j > right) { // 说明右边已经比完了,只需要将左边依次放入
                arr[k] = temp[i] // 放入k位置
                i++ // 左边下一位元素
            } else if(temp[i] > temp[j]) { // 如果左边元素比右边大,右边元素放入k位置
                arr[k] = temp[j] // 将右边元素放入k位置
                j++ // 接着比较右边下一位元素
            } else {
                arr[k] = temp[i] // 将左边元素放入k位置
                i++ // 接着比较左边下一位元素
            }
        }
    }
    // 拆分
    const merge = (arr, left, right) => {
        if (left >= right) return // 数组长度为1 终止递归
        let mid = (left+right)>>>1 // 新学到的取中间值的办法 比Math.floor((left+right)/2) 方便很多
        merge(arr, left, mid) // 分别拆分左右部分
        merge(arr, mid+1, right) // 分别拆分左右部分
        if (arr[mid] > arr[mid+1]) { // 如果左边大于右边,则需要更新顺序
            sort(arr, left, mid, right)
        }
    }
    merge(arr, 0, arr.length-1)
}
// 优化内存方案
function mergeSort2(arr) {
    // 合并排序
    const sort = (arr, left, mid, right, temp) => {
        temp.set(arr.slice(left,right+1), left)
        let i = left // 左边当前比较下标
        let j = mid+1 // 右边当前比较下标
        for(let k = left; k <= right; k++) { // 更新数组种从left到right部分
            // left到mid、mid+1到right已经相对有序 所以只需要两个分段做比较
            // 一开始我是这么写的,但是最后发现一个问题,如果左侧或右侧有一边先比较完了,另一边就会和范围外的进行比较
            // if(temp[i] > temp[j]) { // 如果左边元素比右边大,右边元素放入k位置
            //     arr[k] = temp[j] // 将右边元素放入k位置
            //     j++ // 接着比较右边下一位元素
            // } else {
            //     arr[k] = temp[i] // 将左边元素放入k位置
            //     i++ // 接着比较左边下一位元素
            // }
            // 修改逻辑后如下
            if (i>mid) { // 说明左边已经比完了,只需要将右边依次放入
                arr[k] = temp[j] // 放入k位置
                j++ // 右边下一位元素
            } else if (j > right) { // 说明右边已经比完了,只需要将左边依次放入
                arr[k] = temp[i] // 放入k位置
                i++ // 左边下一位元素
            } else if(temp[i] > temp[j]) { // 如果左边元素比右边大,右边元素放入k位置
                arr[k] = temp[j] // 将右边元素放入k位置
                j++ // 接着比较右边下一位元素
            } else {
                arr[k] = temp[i] // 将左边元素放入k位置
                i++ // 接着比较左边下一位元素
            }
        }
    }
    // 拆分
    const merge = (arr, left, right, temp) => {
        if (left >= right) return // 数组长度为1 终止递归
        let mid = (left+right)>>>1 // 新学到的取中间值的办法 比Math.floor((left+right)/2) 方便很多
        merge(arr, left, mid, temp) // 分别拆分左右部分
        merge(arr, mid+1, right, temp) // 分别拆分左右部分
        if (arr[mid] > arr[mid+1]) { // 如果左边大于右边,则需要更新顺序
            sort(arr, left, mid, right, temp)
        }
    }
    // 生成一个公共备份
    let temp = new Uint8Array(arr.length)
    merge(arr, 0, arr.length-1, temp)
}

总结

优点:
1、稳定,比之时间复杂度相同的快排,不管是相对位置稳定性,还是在不同样本的稳定性,归并排序都占优势。
2、高效,时间复杂度为O(nlogn),适用于大规模数据排序。
3、实用,使用递归或者迭代,适用于大多数数据结构排序。
缺点:
1、内存开销大。
2、实现比较复杂,对于初学者来说不好理解。

七、希尔排序

首先得搞明白什么是希尔排序,希尔排序是由唐纳德·希尔发明的,是对直接插入排序的改进,又叫递减增量算法。
不记得直接插入排序算法的可以向上回顾一下第二章节插入排序。
我们知道插入排序有两个特点,一是当数组趋近于有序时,交换和比较次数较少,效率很高;二是排序元素较少时效率很高。
希尔排序就是利用了插入排序的这个特点,进行改进而产生的算法。

步骤

1、先将待排序数组等分为若干个子数组,对子数组进行插入排序。
2、缩小划分子数组的长度,再次进行插入排序。
3、知道子数组长度为1时,进行插入排序,排序完成。
希尔排序的核心就是如何划分子数组。
要将一个长度为m数组划分为n个子数组,我们最直接的思路就是按顺序等分划分,那么每份的长度为i = m/n(假设能整除),那么每份分别时0到i-1,i到2i-1,…,m-i到m-1。
例如要将一个长度为9的数组进行排序。
在这里插入图片描述
这样划分显然是不行的,虽然子数组内部相对有序了,但是之于整个数组,还是乱序的。
希尔排序使用的是增量划分的方式。如下图。
在这里插入图片描述
可以看出,比之前的划分方式,经过一次排序之后数组已经相对有序了。
接着往下,缩小增量进行划分排序直到增量为1。
在这里插入图片描述
这样,一次完整的希尔排序就完成啦。
由上可以看出,增量初始值,和每次递减值影响了排序次数,所以怎么选取初始增量,和递减值就显得非常重要。只要增量序列中的所有元素都从大到小排序,并且最后一个元素为1,那么该增量序列就可以用于希尔排序。这样的增量序列已经提出很多,我这里就选取其中一种常用的。
公式为2^(t - k + 1) - 1,参数t是一共要进行的次数而k代表每一次;1 ≤ k ≤ t,并且t ≤ log2(n+1),其中n是待排序数组的长度;该公式产生的增量序列为[… , 15, 7, 3, 1]。

流程图

在这里插入图片描述

代码实现

function shellSort(arr) {
    let len = arr.length // 待排序数组长度
    let t = Math.floor(Math.log2(len + 1)) // 总排序次数
    let k // 当前排序次数
    let temp // 交换容器
    for(k = 1; k <= t; k++) { 
        let increment = Math.pow(2, (t - k + 1)) -1 // 当前增量
        for(let i = increment; i < len; i++) { // 增量之前的数为每个子数组的第一个元素,所以从增量开始插入即可
            if (arr[i] < arr[i-increment]) { // 如果这一个元素比上一个元素小, 需要进行插入
                temp = arr[i]
                for(var j = i-increment; j>=0 && arr[j] > temp; j-=increment) { // 找到插入位置前,所有元素后移
                    arr[j+increment] = arr[j]
                } 
                arr[j+increment] = temp // 存入插入位置
            }
        }
    }
}

算法分析

希尔排序的时间复杂度和所选择的增量序列相关,本文使用的2^(t - k + 1) - 1时间复杂度为:
O(n^3/2),那么还有没有更好的时间复杂度呢?只有靠大家去探索发现了。
空间复杂度为O(1)

算法稳定性分析

和插入排序不同的是,因为希尔排序使用增量间隔,所以移动时相等元素的相对位置可能会发生改变,所以希尔排序是不稳定的。

总结

优点:
1、效率较高,处理大规模数据时性能较好。
2、实现方式简单。
缺点:
1、希尔排序性能取决于所选择的增量序列,不同数据规模下,要选取适合的增量序列不容易。
2、不是一个稳定的排序算法,在一些场景下不适用。

八、桶排序

步骤

桶排序使用的是分治思想,将待排序数据放入若干个桶中,对每个桶分别进行排序,最后再合并起来。
1、创建若干个桶。
2、遍历待排序数组,将数据分别放入对应的桶中。
3、对每个桶进行排序。
4、合并每个桶的数据。
因为不同数据排序,桶的划分方式不一样,本文以[0,100)范围内整数为例。
如下图:
在这里插入图片描述

流程图

在这里插入图片描述

代码实现

// 0-100范围整数集排序
function bucketSort(arr) {
    const bucket = Array.from({ length: 10 }, ()=> []) // 创建十个桶
    for(let i = 0; i  < arr.length; i++) { // 遍历数组
        let index = Math.floor(arr[i]/10) // 计算放入哪个桶
        bucket[index].push(arr[i]) // 放入对应桶中
    }
    for(let j = 0; j < 10; j++) { // 依次对每个桶中的数据进行排序 这里使用插入排序
        for(let k = 1; k < bucket[j].length; k++) {
            if (bucket[j][k] < bucket[j][k-1]) { // 如果后面比前面小,需要寻找插入位置
                let temp = bucket[j][k]
                for(var l = k-1; l >= 0 && bucket[j][l] > temp; l--) {
                    bucket[j][l+1] = bucket[j][l]
                }
                bucket[j][l+1] = temp
            }
        }
    }
    arr.length = 0 //清空原数组
    for(let j = 0; j < 10; j++) { // 依次将桶中的数据放入元素组
        for(let k = 0; k < bucket[j].length; k++) {
            arr.push(bucket[j][k])
        }
    }
}

算法分析

桶排序算法时间复杂度为O(n)。
空间复杂度也为O(n)。

算法稳定性分析

桶操作是先进先出,本身是稳定的,所以桶排序算法取决于桶内部排序使用什么排序方式。本文使用插入排序,因为插入排序是稳定的,所以桶排序就是稳定的。

总结

优点:
1、是一种线性排序算法,时间复杂度为O(n),效率较高。
2、应用灵活,桶排序使用分治思想将待排序数组拆分,差分后排序方法选择十分灵活。
缺点:
1、因为需要新开辟桶的占用空间,内存开销较大。
2、由于每个桶中分得的数据量越平均,排序效率越高,所以桶排序只适用于离散的数据排序。
3、桶排序需要根据数据特点确认桶划分方案,需要较强的数据集处理能力。

九、基数排序

步骤

基数排序的原理为依次让待排序数组中的个十百千…位相对有序,最后就能形成一个有序数组,是在桶排序的基础上演变而来。
1、将待排序数组中的每个元素,按个位数排序放入10个桶中。
2、将这十个桶中的数据从0-9,从下到上依次取出形成新的数组。
3、将待排序数组中的每个元素,按十位数排序放入10个桶中。
4、将这十个桶中的数据从0-9,从下到上依次取出形成新的数组。
5、重复上述操作,直到完成待排序数组元素中最高位排序,排序就完成了。
如下图:
在这里插入图片描述

流程图

在这里插入图片描述

代码实现

function radixSort(arr) {
    // 获取当前数组最大位数
    const getMaxDigit = (arr) => {
        let max = 0
        arr.forEach(item => {
            const digit = item.toString().length
            if (digit > max) {
                max = digit
            }
        })
        return max
    }
    let maxDigit = getMaxDigit(arr) // 获取数组最大位数
    let mod = 10; // 取余
    let dev = 1;  // 去尾
    for(i = 1; i <= maxDigit; i++) { // 有多少位数,进行多少次排序 
        const bucket = Array.from({ length: 10 }, ()=> []) // 创建十个桶
        for(let j = 0; j < arr.length; j++) {
            let digit = Math.floor((arr[j]%mod)/dev) // 获取元素第i位
            bucket[digit].push(arr[j]) // 放入对应桶中
        }
        arr.length = 0 //清空原数组
        for(let j = 0; j < 10; j++) { // 依次将桶中的数据放入元素组
            for(let k = 0; k < bucket[j].length; k++) {
                arr.push(bucket[j][k])
            }
        }
        // 更新取值参数,准备取下一位
        mod *= 10
        dev *= 10
    }
}

算法分析

基数排序一共需要经过d次排序,每次排序里面需要进行n+k次操作,所以基数排序的时间复杂度为O(d*(n+k))。其中,d为待排序数组中最大值的位数,n为待排序数组长度,k根据待排序数组的取值范围波动,一般为10。
基数排序需要额外开辟桶的空间,桶中存放所有元素,所以基数排序空间复杂度为O(n+k),k一般为10,近似可以看着空间复杂度为O(n)。

算法稳定性分析

基数排序两个相等元素总是会被放入同一个桶中,放入桶中和从桶中移出遵循先进先出,所以相等元素的相对位置不会发生改变,基数排序算法是稳定的。

总结

优点:
1、时间复杂度打破比较排序的O(nlogn),效率高。
2、其他数据结构多关键字排序同样适用。
3、稳定,不会打乱原数组相等元素的相对位置。
缺点:
1、排序次数受数据最大位数影响,位数越大,排序次数越多。
2、需要重新开辟大量空间,数据量大时会占用过多的存储空间。

十、计数排序

步骤

1、计算待排序数组最大值max,最小值min。
2、创建max-min+1长度的计数容器。
3、遍历数组对每个出现的值在计数容器中对应下标计数。
4、计数数组从前往后累计得到每个计数下标在结果数组中的位置。
5、从后往前依次根据计数容器中的位置将元素放入结果数组对应位置。
如下图所示:
在这里插入图片描述

流程图

在这里插入图片描述

代码实现

function countSort(arr) {
    // 获取数组最大最小值
    const getMaxAndMin = (arr) => {
        let max = arr[0]
        let min = arr[0]
        arr.forEach(item => {
            if (item > max) {
                max = item
            }
            if (item < min) {
                min = item
            }
        })
        return {max,min}
    }
    // 获取数组最大最小值
    let {max, min} = getMaxAndMin(arr)
    let countArray = Array.from({ length: max-min+1 }, ()=>0) // 创建计数数组
    for(let i = 0; i < arr.length; i++) { // 遍历数组计数
        countArray[arr[i]-min]++
    }
    for(let i = 1; i < countArray.length; i++) { // 遍历计数数组依次累加
        countArray[i] += countArray[i-1]
    }
    console.log(countArray)
    let resultArr = Array.from({ length: arr.length })
    for(let i = arr.length-1; i >=0; i--) { // 从后往前依次将元素根据计数数组查找下标放入结果数组对应位置
        let index = countArray[arr[i]-min] - 1
        resultArr[index] = arr[i]
        countArray[arr[i]-min]--
    }
    for(let i = 0; i < arr.length; i++) { // 拷贝回原数组
        arr[i] = resultArr[i]
    }
}

算法分析

计数排序的时间复杂度为O(n+k),n为待排序数组长度,k为待排序数组值的范围。由此可知,待排序数组值范围越小,排序效率越高。
计数排序的空间复杂度为O(n+k),n为排序结果缓存,k为计数容器大小。

算法稳定性分析

计数排序的计数最后存储的是对应值的最后一位下标,然后填入结果数组时从后往前遍历,这样相等元素的相对位置不会发生改变,所以计数排序是稳定的。

总结

优点:
1、计数排序为线性排序,时间复杂度低,效率很高。
2、迭代即可实现,代码简洁。
缺点:
1、常规实现方式只能进行自然数数组排序,局限较大。
2、存储空间占用不可控,待排序数组值域范围越大,所需存储空间就越大。

总结

算法对比

在这里插入图片描述
对比测试上述十种算法在不同大小、不同范围的数据样本下的排序表现。
在这里插入图片描述
从上图结果来看,我们上文中的结论是正确的。
1、随着数据样本增大,冒泡、插入、选择三种排序的用时爆炸增长,符合时间复杂度为O(n²)的特性;快排、归并、堆排序、希尔等也符合O(nlogn)、O(n^3/2)的增长曲线,三个非比较类排序算法在小数据范围时均表现较好,时间线性增长。
2、随着数据样本取值范围增大,计数排序受响应最大,因为计数排序时间复杂度与空间复杂度均与取值范围有关,另一个受影响较大的是基数排序。桶排序在非比较类算法中受影响较小,原因为数据样本在范围内随机生成,离散性较好,而且根据不同样本范围调整了桶的数量。
3、值得一提的是,数据样本增大至1000万以后,样本取值范围也对希尔排序产生了很大的影响,其原因为取值范围增大后,每次缩小增量后排序时,相对顺序乱序的几率也更大。
4、整个测试过程中,表现最好的是桶排序,也确实不辜负O(n)的复杂度,不过这也和生成数据样本的方式有关,待排序数组都是离散的整数。其次是堆排序>归并排序>希尔排序,chrome环境下快排倒在1000万条数据。
排序算法推荐:
离散数据类型:桶排序
大数据量排序:堆排序>归并排序>希尔排序
小范围非离散数据排序:计数排序>基数排序
大数据量稳定排序:归并排序
简单例举了几种排序场景,排序算法应用场景多种多样,还是需要根据具体情况和个人掌握情况选择适合的算法。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值