整理8个排序算法原理和性能分析

348 阅读6分钟

我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第3篇文章,点击查看活动详情

整理8个排序算法的原理,时间复杂度,稳定性,空间复杂度和代码示例。以最快方式了解排序算法方式和理解.

复杂度归类

  • 时间复杂度 O(n^2)

    • 冒泡排序
    • 插入排序
    • 选择排序
  • 时间复杂度O(nlogn)

    • 快速排序
    • 归并排序
  • 时间复杂度O(n)

    • 计数排序
    • 基数排序
    • 桶排序

分析排序算法

  • 算法的执行效率

    • 最好、最坏、平均情况时间复杂度。
    • 时间复杂度的系数、常数和低阶。
    • 比较次数,交换(或移动)次数。
  • 排序算法的稳定性

    • 稳定性概念:如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。
    • 稳定性重要性:可针对对象的多种属性进行有优先级的排序。
  • 排序算法的内存损耗

    • 原地排序算法:特指空间复杂度是O(1)的排序算法。

排序分析

冒泡排序

  • 排序原理

    • 冒泡排序只会操作相邻的两个数据。
    • 每次冒泡操作都会对相邻的两个元素进行比较
    • 看是否满足大小关系要求,如果不满足就让它俩互换
    • 一次冒泡会让至少一个元素移动到它应该在的位置,重复n次,就完成了n个数据的排序工作
    • 优化:若某次冒泡不存在数据交换,则说明已经达到完全有序,所以终止冒泡
  • 性能分析

    • 时间复杂度

      • 最好情况时间复杂度:数据完全有序时,只需进行一次冒泡操作即可,时间复杂度是O(n)
      • 最坏情况时间复杂度:数据倒序排序时,需要n次冒泡操作,时间复杂度是O(n^2)
      • 平均情况时间复杂度:通过有序度和逆序度分析,时间复杂度是O(n^2)
    • 稳定性:如果两个值相等,就不会交换位置,故是稳定排序算法

    • 空间复杂度:每次交换仅需1个临时变量,故空间复杂度为O(1),是原地排序算法

  • 代码示例

    function bubbleSort(arr) {
      const len = arr.length;
      for (let i = 0; i < len; i++) {
        let flag = false;
        for (let j = 0; j < len - 1 - i; j++) {
          if (arr[j] > arr[j + 1]) {
            [arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
            flag = true;
          }
        }
        if (flag == false) return arr;
      }
      return arr;
    }
    

插入排序

  • 排序原理

    • 数组中的数据分为已排序区间和未排序区间。
    • 初始已排序区间只有一个元素,就是数组的第一个元素
    • 插入算法的核心思想就是取未排序区间中的元素
    • 在已排序区间中找到合适的插入位置将其插入,并保证已排序区间中的元素一直有序
    • 重复这个过程,直到未排序中元素为空,算法结束
  • 性能分析

    • 时间复杂度

      • 最好情况时间复杂度:数组是有序的,不需要搬移任何数据。只要遍历一遍数组,时间复杂度是O(n)。
      • 最坏情况时间复杂度:数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,这时需要移动大量的数据,时间复杂度是O(n^2)。
      • 平均情况时间复杂度:而在一个数组中插入一个元素的平均时间复杂都是O(n),插入排序需要n次插入,平均时间复杂度是O(n^2)。
    • 空间复杂度:插入排序算法的运行并不需要额外的存储空间,所以空间复杂度是O(1),是原地排序算法。

    • 稳定性:

      • 插入排序中值相同的元素,我们可以选择将后面出现的元素
      • 插入到前面出现的元素的后面,这样就保持原有的顺序不变
      • 所以是稳定的,而且是原地排序算法。
  • 代码示例

    function insertSort(arr) {
      // 缓存数组长度
      const len = arr.length;
      let temp;
      for (let i = 1; i < len; i++) {
        let j = i;
        temp = arr[i];
        // 判断 j 前面一个元素是否比 temp 大
        while (j > 0 && arr[j - 1] > temp) {
          arr[j] = arr[j - 1];
          j--;
        }
        arr[j] = temp;
      }
      return arr;
    }
    

选择排序

  • 排序原理

    • 将数组分成已排序区间和未排序区间
    • 初始已排序区间为空
    • 每次从未排序区间中选出最小的元素插入已排序区间的末尾
    • 直到未排序区间为空
  • 性能分析

    • 时间复杂度:无论是否有序,每个循环都会完整执行

      • 最好情况时间复杂度:O(n^2)
      • 最坏情况时间复杂度:O(n^2)
      • 平均情况时间复杂度:O(n^2)
    • 空间复杂度:选择排序算法空间复杂度是O(1),是一种原地排序算法。

    • 稳定性:遇到相同的变量,元素位置会发生改变,他不是稳定的排序算法,相对于冒泡排序和插入排序,选择排序就稍微逊色

  • 代码示例

    function selectSort(arr) {
      // 缓存数组长度
      const len = arr.length;
      // 定义 minIndex
      let minIndex;
      // i 是当前排序区间的起点
      for (let i = 0; i < len - 1; i++) {
        // 初始化 minIndex 为当前区间第一个元素
        minIndex = i;
        for (let j = i; j < len; j++) {
          if (arr[j] < arr[minIndex]) {
            minIndex = j;
          }
        }
        // 如果 minIndex 对应元素不是目前的头部元素,则交换两者
        if (minIndex !== i) {
          [arr[i], arr[minIndex]] = [arr[minIndex], arr[i]];
        }
      }
      return arr;
    }
    

归并排序

  • 排序原理

    • 使用分治思想。分治:顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了

      • 分解:将n个元素分成个含n/2个元素的子序列。
      • 解决:用合并排序法对两个子序列递归的排序。
      • 合并:合并两个已排序的子序列已得到排序结果。
    • 分割:

      • 将数组从中点进行分割,分为左、右两个数组
      • 递归分割左、右数组,直到数组长度小于2
    • 归并:

      • 如果需要合并,那么左右两数组已经有序了。
      • 创建一个临时存储数组temp
      • 比较两数组第一个元素,将较小的元素加入临时数组
      • 若左右数组有一个为空
      • 那么此时另一个数组一定大于temp中的所有元素,
      • 直接将其所有元素加入temp
  • 性能分析

    • 时间复杂度

      • 最好情况时间复杂度:O(nlogn)
      • 最坏情况时间复杂度:O(nlogn)
      • 平均情况时间复杂度:O(nlogn)
    • 空间复杂度:O(n) 临时的数组和递归时压入栈的数据占用的空间:n + logn

    • 稳定性:归并最后到底都是相邻元素之间的比较交换,并不会发生相同元素的相对位置发生变化,所以他是稳定性

  • 代码示例

    function mergeSort(arr) {
      const len = arr.length;
      if (len <= 1) {
        return arr;
      }
      const mid = Math.floor(len / 2);
      const leftArr = mergeSort(arr.slice(0, mid));
      const rightArr = mergeSort(arr.slice(mid, len));
      arr = mergeArr(leftArr, rightArr);
      return arr;
    }
    
    function mergeArr(arr1, arr2) {
      let i = 0,
        j = 0;
      const res = [];
      const len1 = arr1.length;
      const len2 = arr2.length;
      while (i < len1 && j < len2) {
        if (arr1[i] < arr2[j]) {
          res.push(arr1[i]);
          i++;
        } else {
          res.push(arr2[j]);
          j++;
        }
      }
      if (i < len1) {
        return res.concat(arr1.slice(i));
      } else {
        return res.concat(arr2.slice(j));
      }
    }
    

快速排序

  • 排序原理

    • 思想

      • 将排序的数据分割成独立的两部分
      • 其中一部分的数据比另一部分的数据要小
      • 再对这两部分数据分别进行快速排序
      • 整个排序过程可以递归进行,使整个数据变成有序序列
    • 步骤

      • 从数组中挑出一个元素,称为 "基准"(pivot)(一般选择第一个数)
      • 将比基准小的元素移动到数组左边,比基准大的元素移动到数组右边
      • 分别对基准左侧和右侧的元素进行递归排序
  • 性能分析

    • 时间复杂度

      • 最好情况时间复杂度:O(NlogN)
      • 最坏情况时间复杂度:O(n^2)
      • 平均情况时间复杂度:O(NlogN)
    • 空间复杂度:O(logn)(递归调用消耗)

    • 稳定性:原地排序、不稳定的算法

  • 优化性能

    • 三数取中法

      • 从区间的首、中、尾分别取一个数,然后比较大小,取中间值作为分区点。
      • 如果要排序的数组比较大,那“三数取中”可能就不够用了,可能要“5数取中”或者“10数取中”。
    • 随机法:每次从要排序的区间中,随机选择一个元素作为分区点。

    • 警惕快排的递归发生堆栈溢出

      • 限制递归深度,一旦递归超过了设置的阈值就停止递归。
      • 在堆上模拟实现一个函数调用栈,手动模拟递归压栈、出栈过程,这样就没有系统栈大小的限制。
  • 代码示例

    // 快速排序入口
    function quickSort(arr, left = 0, right = arr.length - 1) {
      if (arr.length > 1) {
        const lineIndex = partition(arr, left, right);
        if (left < lineIndex - 1) {
          quickSort(arr, left, lineIndex - 1);
        }
        if (lineIndex < right) {
          quickSort(arr, lineIndex, right);
        }
      }
      return arr;
    }
    // 以基准值为轴心,划分左右子数组的过程
    function partition(arr, left, right) {
      let pivotValue = arr[Math.floor(left + (right - left) / 2)];
      let i = left;
      let j = right;
      while (i <= j) {
        while (arr[i] < pivotValue) {
          i++;
        }
        while (arr[j] > pivotValue) {
          j--;
        }
        if (i <= j) {
          swap(arr, i, j);
          i++;
          j--;
        }
      }
      return i;
    }
    
    function swap(arr, i, j) {
      [arr[i], arr[j]] = [arr[j], arr[i]];
    }
    
    

计数排序

开辟额外空间,统计每个元素的数量

  • 排序原理

    • 计算出待排序序列的最大值 maxValue 与 最小值 minValue
    • 开辟一个长度为 maxValue - minValue + 1 的额外空间
    • 统计待排序序列中每个元素的数量
    • 记录在额外空间中,最后遍历一遍额外空间
    • 按照顺序把每个元素赋值到原始序列中
  • 性能分析

    • 时间复杂度:O ( n + k ) 排序元素是 n 个 0 到 k 之间的整数
    • 空间复杂度:O ( n + k ) 计数排序需要两个额外的数组,分别用于记录元素数量与排序结果
    • 稳定性:计数排序是稳定的排序算法,但不是原地排序
  • 代码示例

    function countingSort(nums) {
      var list = [];
      var max = Math.max(...nums);
      var min = Math.min(...nums);
    
      for (var i = 0; i < nums.length; i++) {
        var temp = nums[i];
        list[temp] = list[temp] + 1 || 1;
      }
    
      var index = 0;
      for (var i = min; i <= max; i++) {
        while (list[i] > 0) {
          nums[index++] = i;
          list[i]--;
        }
      }
    
      return list;
    }
    

桶排序

  • 排序原理

    • 将要排序的数据分到几个有序的桶里
    • 每个通在分别进行排序
    • 每个桶排序完成后再把每个桶里的数据按照顺序依次取出,组成新的序列
    • 该序列就是排好序的序列。类似归并排序中中的分治思想
  • 性能分析

    • 时间复杂度:桶排序是线性时间排序 O(N)

    • 空间复杂度:

      • 需要创建M个桶的额外空间
      • 以及N个元素的额外空间,
      • 桶排序的空间复杂度为 O(N+M)
    • 稳定性

      • 桶排序是稳定排序
      • 如果桶内的排序是选择快速排序,这就不稳定的
      • 不是原地排序
  • 代码示例

    function bucketSort(nums) {
      var num = 5;
      var max = Math.max(...nums);
      var min = Math.min(...nums);
      var range = Math.ceil((max - min) / num) || 1;
      var arr = Array.from(Array(num)).map(() => Array().fill(0));
      nums.forEach((val) => {
        let index = parseInt((val - min) / range);
        index = index >= num ? num - 1 : index;
        let temp = arr[index];
        let j = temp.length - 1;
        while (j >= 0 && val < temp[j]) {
          temp[j + 1] = temp[j];
          j--;
        }
        temp[j + 1] = val;
      });
      var res = [].concat.apply([], arr);
      nums.forEach((val, i) => {
        nums[i] = res[i];
      });
      return nums;
    }
    

基数排序

  • 排序原理

    • 以整数排序为例
    • 将整数按位数划分,准备N个桶,代表 0 -N
    • 根据整数个位数字的数值将元素放入对应的桶中,
    • 按照输入赋值到原序列中,依次对十位、百位等进行同样的操作,最终就完成了排序的操作
  • 性能分析

    • 时间复杂度O(k*(n+m))

    • 空间复杂度O(n+m) m 个桶与存放 n 个元素的空间

    • 稳定性

      • 不会改变相同元素之间的相对位置
      • 元素收回的过程中是从后向前进行的
      • 所以稳定的排序算法,但不是原地排序
  • 代码示例

    function radixSort(nums) {
      function getDigits(n) {
        var sum = 0;
        while (n) {
          sum++;
          n = parseInt(n / 10);
        }
        return sum;
      }
      var arr = Array.from(Array(10)).map(() => Array());
      var max = Math.max(...nums);
      var maxDigits = getDigits(max);
      for (var i = 0, len = nums.length; i < len; i++) {
        nums[i] = (nums[i] + "").padStart(maxDigits, 0);
    
        var temp = nums[i][nums[i].length - 1];
        arr[temp].push(nums[i]);
      }
      for (var i = maxDigits - 2; i >= 0; i--) {
        for (var j = 0; j <= 9; j++) {
          var temp = arr[j];
          var len = temp.length;
          while (len--) {
            var str = temp[0];
            temp.shift();
            arr[str[i]].push(str);
          }
        }
      }
      var res = [].concat.apply([], arr);
      nums.forEach((val, index) => {
        nums[index] = +res[index];
      });
      return nums;
    }
    

排序函数实现技巧

  • 数据量

    • 很小,采取用时间换空间的思路
    • 很多,优化快排分区点的选择
  • 防止堆栈溢出,选择在堆上手动模拟调用栈解决

  • 在排序区间中,当元素个数小于某个常数是,可以考虑使用O(n^2)级别的插入排序

  • 用哨兵简化代码,每次排序都减少一次判断,尽可能把性能优化到极致