JavaScript实现排序算法

328 阅读14分钟

一、前言

时间复杂度和空间复杂度

时间复杂度是指执行算法所需要的计算工作量;而空间复杂度是指执行这个算法所需要的内存空间。(算法的复杂性体现在运行该算法时的计算机所需资源的多少上,计算机资源最重要的是时间和空间(即寄存器)资源,因此复杂度分为时间和空间复杂度

在描述算法复杂度时,经常用到o(1), o(n), o(logn), o(nlogn)来表示对应算法的时间复杂度。这里进行归纳一下它们代表的含义:这是算法的时空复杂度的表示。不仅仅用于表示时间复杂度,也用于表示空间复杂度。一个算法的优劣主要从算法的所需时间和所占用的空间两个方面衡量。一般空间利用率小的,所需时间相对较长。所以性能优化策略里面经常听到 空间换时间,时间换空间这样说法

二、大O表示法

大O表示法:

  • 在计算机中采用粗略的度量来描述计算机算法的效率,这种方法被称为 “大O”表示法
  • 数据项个数发生改变时,算法的效率也会跟着改变。所以说算法A比算法B快两倍,这样的比较是没有意义的。
  • 因此我们通常使用算法的速度随着数据量的变化会如何变化的方式来表示算法的效率,大O表示法就是方式之一。

常见的大O表示形式

符号名称
O(1)常数
O(log(n))对数
O(n)线性
O(nlog(n))线性和对数乘积
O(n²)平方
O(2^n)指数

不同大O形式的时间复杂度:

104.png

可以看到效率从大到小分别是:O(1)> O(logn)> O(n)> O(nlog(n))> O(n²)> O(2^n)

推导大O表示法的三条规则:

  • 规则一:用常量1取代运行时间中所有的加法常量。如7 + 8 = 15,用1表示运算结果15,大O表示法表示为O(1);
  • 规则二:运算中只保留最高阶项。如N^3 + 3n +1,大O表示法表示为:O(N^3);
  • 规则三:若最高阶项的系数不为1,可将其省略。如4N^2,大O表示法表示为:O(N^2);

三、排序算法

132.png

  • 插入类排序:插入排序、希尔排序;
  • 选择类排序:选择排序、堆排序;
  • 交换类排序:冒泡排序、快速排序;
  • 其他排序:归并排序、基数排序;

插入类排序

1.插入排序

插入排序的思路:

  • 插入排序思想的核心是局部有序。如图所示,X左边的人称为局部有序
  • 首先指定一数据X(从第一个数据开始),并将数据X的左边变成局部有序状态;
  • 随后将X右移一位,并与前面的进行对比后插入到合适的位置,重复前面的操作直至X移至最后一个元素。

112.png

插入排序的详细过程

113.png

动态过程

114.gif

代码实现:

// 1.插入排序
function insertionSort(arr) {
  let res = [...arr];
  for (let i = 1; i < res.length; i++) {
    let temp = res[i];
    let j = i;
    while (j > 0 && res[j - 1] > temp) {
      res[j] = res[j - 1];
      j--;
    }
    res[j] = temp;
  }
  return res;
}
​
// 测试部分
let arr = [3, 6, 4, 2, 11, 10, 5];
let result = insertionSort(arr);
console.log(result);// [2,3,4,5,6,10,11]

插入排序的效率:

  • 比较次数: 第一趟时,需要的最大次数为1;第二次最大为2;以此类推,最后一趟最大为N-1;所以,插入排序的总比较次数最多为N * (N - 1) / 2;但是,实际上每趟发现插入点之前,平均只有全体数据项的一半需要进行比较,所以比较次数为:N * (N - 1) / 4
  • 交换次数: 指定第一个数据为X时交换0次,指定第二个数据为X最多需要交换1次,以此类推,指定第N个数据为X时最多需要交换N - 1次,所以一共需要交换最多N * (N - 1) / 2次,平均次数为N * (N - 1) / 4
  • 虽然用大O表示法表示插入排序的效率也是O(N^2) ,但是插入排序整体操作次数更少,因此在插入排序、选择排序、冒泡排序中,插入排序效率最高

2.希尔排序

希尔排序插入排序的一种高效的改进版,效率比插入排序要高。

希尔排序的历史背景:

  • 希尔排序按其设计者希尔(Donald Shell)的名字命名,该算法由1959年公布;
  • 希尔算法首次突破了计算机界一直认为的算法的时间复杂度都是O(N^2)的大关,为了纪念该算法里程碑式

的意义,用Shell来命名该算法;

插入排序的问题:

  • 假设一个很小的数据项很靠近右端的位置上,这里本应该是较大的数据项的位置
  • 将这个小数据项移动到左边的正确位置,所有的中间数据项都必须向右移动一位,这样效率非常低;
  • 如果通过某种方式,不需要一个个移动所有中间的数据项,就能把较小的数据项移到左边,那么这个算法的执行速度就会有很大的改进。

希尔排序的实现思路:

  • 希尔排序主要通过对数据进行分组,而达到分组插入的效果;
  • 根据设定的增量(gap)将数据分为gap个组,再在每个分组中进行插入排序;

假如有数组有10个数据,第1个数据为黑色,增量为5。那么第二个为黑色的数据index=5,第3个数据为黑色的数据index = 10。所以黑色的数据每组只有2个,10 / 2 = 5一共可分5组,即组数等于增量gap

  • 排序之后,减小增量,继续分组,再次进行局部排序,直到增量gap=1为止。随后只需进行微调就可完成数组的排序;

具体过程如下

  • 排序之前的,储存10个数据的原始数组为:

116.png

  • 设初始增量gap = length / 2 = 5,即数组被分为了5组,如图所示分别为:[8, 3]、[9, 5]、[1, 4]、[7, 6]、[2, 0]:

117.png

  • 随后分别在每组中对数据进行插入排序,5组的顺序如图所示,变为:[3, 8]、[5, 9]、[1, 4]、[6, 7]、[0, 2]:

118.png

  • 然后缩小增量gap = 5 / 2 = 2,即数组被分为了2组,如图所示分别为:[3,1,0,9,7]、[5,6,8,4,2]:

119.png

  • 随后分别在每组中对数据进行插入排序,两组的顺序如图所示,变为:[0,1,3,7,9]、[2,4,5,6,8]:

120.png

  • 然后然后缩小增量gap = 2 / 1 = 1,即数组被分为了1组,如图所示为:[0,2,1,4,3,5,7,6,9,8]:

121.png

  • 最后只需要对该组数据进行插入排序即可完成整个数组的排序:

122.png

动态过程

123.gif

图中d表示增量gap。

增量的选择:

  • 原稿中希尔建议的初始间距为N / 2,比如对于N = 100的数组,增量序列为:50,25,12,6,3,1,可以发现不能整除时向下取整。
  • Hibbard增量序列: 增量序列算法为:2^k - 1,即1,3,5,7... ...等;这种情况的最坏复杂度为O(N3/2)* ,平均复杂度为* O(N5/4) 但未被证明;
  • Sedgewcik增量序列:

124.png

代码实现:

以下代码实现中采用希尔排序原稿中建议的增量即N / 2

// 2.希尔排序
function shellSort(arr) {
  // 为了不改变原数组进行一个浅拷贝
  let res = [...arr];
  // 初始化增量
  let gap = Math.floor(res.length / 2);
  // 第一层循环:while循环(使gap不断减小)
  while (gap >= 1) {
    // 第二层循环:以gap为增量,进行分组,对分组进行插入排序
    // 注意点:将i = gap作为选中的第一个数据,i++对每一组进行了区分
    for (let i = gap; i < res.length; i++) {
      let temp = res[i];
      let j = i;
      // 第三层循环:寻找正确的插入位置
      // 注意点:j>gap-1,不能等于,因为最后一组的j值最小为gap-1
      while (j > gap - 1 && res[j - gap] > temp) {
        res[j] = res[j - gap];
        j -= gap;
      }
      //将j位置的元素设置为temp
      res[j] = temp;
    }
​
    gap = Math.floor(gap / 2);
  }
  return res;
}
​
// 测试部分
let arr = [8, 9, 1, 7, 2, 3, 5, 4, 6, 0];
let result = shellSort(arr);
console.log(result); // [0,1,2,3,4,5,6,7,8,9]

这里解释一下上述代码中的三层循环:

  • 第一层循环: while循环,控制gap递减到1;
  • 第二层循环: 分别取出根据g增量gap分成的gap组数据:将index = gap的数据作为选中的第一个数据,如下图所示,gap=5,则index = gap的数据为3,index = gap - 1的数据为8,两个数据为一组。随后gap不断加1右移,直到gap < length,此时实现了将数组分为5组。(其中gap是指分组)

125.png

  • 第三层循环: 对每一组数据进行插入排序;

希尔排序的效率:

  • 希尔排序的效率和增量有直接关系,即使使用原稿中的增量效率都高于直接插入排序。

选择类排序

3.选择排序

选择排序改进了冒泡排序:

  • 交换次数O(N^2) 减小到O(N)
  • 但是比较次数依然是O(N^2)

选择排序的思路:

  • 选定第一个索引的位置比如1,假设其为最小,然后依次和后面的元素依次进行比较
  • 如果后面的元素,有小于位置1的元素,则将其标记为最小(如下面的位置3),再继续用位置3的元素与后面的元素进行比较;
  • 找到最小的元素后将其与位置1的元素经行交换,此时位置1的元素是最小的。之后再从第二个元素开始,以此类推。。。

109.png

实现思路: 两层循环

  • 外层循环控制指定的索引:

    • 第一次:i = 0,指定第一个元素 ;
    • 最后一次:i = length - 2,指定为倒数第二个元素 (因为最后一次不需要比较);
  • 内层循环负责从i+1位置开始,和后面的元素进行比较,选出最小值的索引;

动态过程

110.gif

代码实现:

// 3.选择排序
function selectionSort(arr) {
  let res = [...arr];
  //外层循环:从0开始获取元素,直到倒数第二个元素为止
  for (let i = 0; i < res.length - 1; i++) {
    let min = i;
    //内层循环:从i+1位置开始,和后面的元素进行比较
    for (let j = i + 1; j < res.length; j++) {
      if (res[min] > res[j]) {
        min = j;
      }
    }
    // 将最小的元素和第i个元素进行交换
    let temp = res[i];
    res[i] = res[min];
    res[min] = temp;
  }
  return res;
}
​
// 测试部分
let arr = [2, 5, 3, 6, 9, 5, 7, 1, 10, 4];
let result = selectionSort(arr);
console.log(result);

选择排序的效率:

  • 选择排序的比较次数为:N * (N - 1) / 2,用大O表示法表示为:O(N^2) ;
  • 选择排序的交换次数为:(N - 1) / 2,用大O表示法表示为:O(N) ;
  • 在插入排序、选择排序、冒泡排序中,选择排序效率中等

4.堆排序

堆的介绍

  • 堆是一个完全二叉树。
  • 完全二叉树:二叉树除开最后一层,其他层结点数都达到最大,最后一层的所有结点都集中在左边(左边结点排列满的情况下,右边才能缺失结点)。
  • 大顶堆:根结点为最大值,每个结点的值大于或等于其孩子结点的值。
  • 小顶堆:根结点为最小值,每个结点的值小于或等于其孩子结点的值。
  • 对于结点 i ,其子结点为2*i+12*i+2,父节点为Math.floor((i-1)/2)
  • 堆的存储:堆由数组来实现。如下图:

133.png

134.png

构建大顶堆

每次构建大顶堆都是从非叶子结点的最后一个结点开始Math.floor(A.length / 2 - 1),然后逐步向根节点构建。如下:

135.png

构建完成后进行堆排序

先构建好一个大顶堆,然后把根结点拿出来与最后一个节点进行交换(交换后的最后一个节点会被排除在外),交换后从根节点开始再次调用构建堆的函数进行堆的调整。调整完毕后再次重复,直到只剩一个结点为止。

136.png

代码实现

// 4.堆排序
// 定义一个处理交换的函数
function swap(arr, a, b) {
  let temp = arr[a];
  arr[a] = arr[b];
  arr[b] = temp;
}
// 构建大顶堆:使某个非叶子节点下面的子节点符合大顶堆
function adjustHeap(A, i, length) {
  let temp = A[i]; // 当前父节点(非叶子节点)
  // j<length 的目的是对结点 i 以下的结点全部做顺序调整
  for (let j = 2 * i + 1; j < length; j = 2 * j + 1) {
    temp = A[i]; // 将 A[i] 取出,整个过程相当于找到 A[i] 应处于的位置
    if (j + 1 < length && A[j] < A[j + 1]) {
      j++; // 找到两个孩子中较大的一个,再与父节点比较
    }
    if (temp < A[j]) {
      swap(A, i, j); // 如果父节点小于子节点:交换;否则跳出
      i = j; // 交换后,temp 的下标变为 j,用于后续节点的的比较
    } else {
      break;
    }
  }
}
function heapSort(A) {
  // 初始化大顶堆,从第一个非叶子结点开始
  for (let i = Math.floor(A.length / 2 - 1); i >= 0; i--) {
    adjustHeap(A, i, A.length);
  }
  // 排序,每一次for循环找出一个当前最大值,数组长度减一
  for (let i = Math.floor(A.length - 1); i > 0; i--) {
    swap(A, 0, i); // 根节点与最后一个节点交换
    // 从根节点开始调整,并且交换到后面去了的结点不需要再参与比较,所以第三个参数为 i
    adjustHeap(A, 0, i);
  }
}
​
// 测试部分
// 把该数组看成一个无序的二叉树
let arr = [4, 6, 8, 5, 9, 1, 2, 5, 3, 2];
heapSort(arr); // 会改变原数组
console.log(arr);// [1, 2, 2, 3, 4, 5, 5, 6, 8, 9]

交换类排序

5.冒泡排序

冒泡排序的思路:

  • 对未排序的各元素从头到尾依次比较相邻的两个元素大小关系;
  • 如果左边的人员高,则将两人交换位置。比如1比2矮,不交换位置;
  • 右移动一位,继续比较2和3,最后比较 length - 1 和 length - 2这两个数据;
  • 当到达最右端时,最高的人一定被放在了最右边
  • 按照这个思路,从最左端重新开始时,只需要走到倒数第二个位置即可;

105.png

实现思路:

两层循环:

  • 外层循环控制冒泡趟数:

    • 第一次:j = length - 1,比较到倒数第一个位置 ;
    • 第二次:j = length - 2,比较到倒数第二个位置 ;
  • 内层循环控制每趟比较的次数:

    • 第一次比较: i = 0,比较 0 和 1 位置的两个数据;
    • 最后一次比较:i = length - 2,比较length - 2和 length - 1两个数据;

详细过程如下图所示

106.png

动态过程

107.gif

代码实现:

// 5.冒泡排序
function bubbleSort(arr) {
  let res = [...arr];
  //外层循环控制冒泡趟数
  for (let j = arr.length - 1; j > 0; j--) {
    //内层循环控制每趟比较的次数
    for (let i = 0; i < j; i++) {
      if (res[i] > res[i + 1]) {
        let temp = res[i];
        res[i] = res[i + 1];
        res[i + 1] = temp;
      }
    }
  }
  return res;
}
​
// 测试部分
let arr = [3, 6, 4, 2, 11, 10, 5];
let result = bubbleSort(arr);
console.log(result);// [2,3,4,5,6,10,11]

冒泡排序的效率:

  • 上面所讲的对于7个数据项,比较次数为:6 + 5 + 4 + 3 + 2 + 1;
  • 对于N个数据项,比较次数为:(N - 1) + (N - 2) + (N - 3) + ... + 1 = N * (N - 1) / 2;如果两次比较交换一次,那么交换次数为:N * (N - 1) / 4;
  • 使用大O表示法表示比较次数和交换次数分别为:O( N * (N - 1) / 2)和O( N * (N - 1) / 4),根据大O表示法的三条规则都化简为:O(N^2) ;

6.快速排序

快速排序的介绍:

  • 快速排序可以说是目前所有排序算法中,最快的一种排序算法。当然,没有任何一种算法是在任意情况下都是最优的。但是,大多数情况下快速排序是比较好的选择。
  • 快速排序其实是冒泡排序的升级版;

快速排序的核心思想是分而治之,先选出一个数据(比如65),将比其小的数据都放在它的左边,将比它大的数据都放在它的右边。这个数据称为枢纽

和冒泡排序的不同:

  • 我们选择的65可以一次性将它放在最正确的位置,之后就不需要做任何移动;
  • 而冒泡排序即使已经找到最大值,也需要继续移动最大值,直到将它移动到最右边;

127.png

快速排序的枢纽:

  • 第一种方案: 直接选择第一个元素作为枢纽。但是,当第一个元素就是最小值的情况下,效率不高;
  • 第二种方案: 使用随机数。随机数本身十分消耗性能,不推荐;
  • 第三种方案: 取index为头、中、尾的三个数据排序后的中位数;如下图所示,按下标值取出的三个数据为:92,43,0,经排序后变为:0,43,92,取其中的中位数43作为枢纽(当(length-1)/2不整除时可向下取整):

128.png

实现枢纽选择: 使用第三种方案

// 定义一个处理交换的函数
function swap(arr, a, b) {
  let temp = arr[a];
  arr[a] = arr[b];
  arr[b] = temp;
}
// 定义一个选出中间值的函数
function median(arr) {
  // 从数组的左中右位置选出中等大小的值并将这三个值排好序
  let left = 0;
  let right = arr.length - 1;
  let center = Math.floor((left + right) / 2);
  // 进行交换,这里if语句的顺序不能随意改变
  if (arr[left] > arr[center]) {
    swap(arr, left, center);
  }
  if (arr[left] > arr[right]) {
    swap(arr, left, right);
  }
  if (arr[center] > arr[right]) {
    swap(arr, center, right);
  }
  // 返回中间值的索引
  return center;
}

数组经过获取枢纽函数操作之后,选出的3个下标值对应的数据位置变为:

129.png

动态过程:

130.gif

快速排序代码实现:

// 6.快速排序
function QuickSort(arr) {
  if (arr.length == 0) {
    return [];
  }
  // 获取合适的枢纽
  let center = median(arr);
  let c = arr.splice(center, 1);
  let l = [];
  let r = [];
  for (let i = 0; i < arr.length; i++) {
    if (arr[i] > c) {
      r.push(arr[i]);
    } else {
      l.push(arr[i]);
    }
  }
  return QuickSort(l).concat(c, QuickSort(r));
}
​
// 测试部分
let arr = [92, 13, 81, 43, 31, 27, 56, 0];
// 会改变原数组
let result = QuickSort(arr);
console.log(result);

快速排序的效率:

  • 快速排序最坏情况下的效率:每次选择的枢纽都是最左边或最右边的数据,此时效率等同于冒泡排序,时间复杂度最差为O(n^2)。可根据不同的枢纽选择避免这一情况;
  • 快速排序的平均效率:为O(N*logN) ,虽然其他算法效率也可达到O(N*logN),但是其中快速排序是最好的

其他排序

7.归并排序

8.基数排序