Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

经典排序算法 #23

Open
FridaS opened this issue Mar 16, 2019 · 0 comments
Open

经典排序算法 #23

FridaS opened this issue Mar 16, 2019 · 0 comments
Labels
算法 计算机基础 计算机基础知识

Comments

@FridaS
Copy link
Owner

FridaS commented Mar 16, 2019

2019年03月16日

经典排序算法

写在前面

  1. 稳定性:如果待排序的序列中存在值相等的元素,经过排序后,相等元素之间原有的先后顺序不变,我们就说这种排序算法是稳定的排序算法。

  2. 原地排序:是针对空间复杂度引入的一个概念,它特指指空间复杂度为O(1)的排序算法。

  3. 为了下面时间复杂度分析方便,我们先介绍下"有序度"和"逆序度"。
    有序度是数组中具有有序关系的元素对的个数,逆序度是数组中具有逆序关系的元素对的个数:

    有序元素对:a[i] <= a[j] ,如果 i<j

    逆序元素对:a[i] > a[j],如果i<j

    比如[2,4,3,1,5,6]这组数组,其有序元素对有:
    (2,4)、(2,3)、(2,5)、(2,6)、(4,5)、(4,6)、(3,5)、(3,6)、(1,5)、(1,6)、(5,6) 共11个,所以其有序度为11;

    其逆序元素对有:
    (2,1)、(4,3)、(4,1)、(3,1) 共4个,所以其逆序度为4。

    比如[6,5,4,3,2,1]的有序度是0;

    而对于[1,2,3,…,n]这样完全有序的数组,其有序度就是
    $$
    C_n^2 = \frac{n(n-1)}{2}
    $$
    我们把这种完全有序的数组有序度叫做满有序度

    比如[1,2,3,4,5,6]的满有序度为15。

    有序度 + 逆序度 = 满有序度。我们排序的过程就是一种增加有序度、减少逆序度的过程,最后达到满有序度、就说明排序完成了。
    对数组进行交换以使其逐渐有序,假设每交换一次有序度就加1,那么不管算法如何改进、交换次数总是确定的,即为逆序数

  4. 下面的排序算法都实现升序排序。

冒泡排序

  1. 对相邻的两个元素进行比较,看是否满足大小关系要求,不满足就进行交换(比如升序排序,比较相邻元素前一个元素是否比后一个大,如果大、就进行交换);
  2. 每一次冒泡都会有一个元素移动到它应该在的位置(就像水里的泡泡冒到水面上了);
  3. 重复n次,就把n个元素放到其应该在的位置,也就排好序了。

优化:当某次冒泡已经没有数据交换了,说明已经达到有序了,也就可以不再执行后续冒泡。

代码:

// 冒泡排序
function bubbleSort (array) {
  if (!array || array.length <= 1) {
    return;
  }
  let len = array.length;
  for (let i=0; i<len; i++) {
    let flag = false; // 本轮冒泡是否有数据交换
    for (let j=0; j<len-i-1; j++) {
      if (array[j]>array[j+1]) {
        let temp = array[j+1];
        array[j+1] = array[j];
        array[j] = temp;
        flag = true;
      }
    }
    if (!flag) {
      break; // 本轮冒泡没有数据交换,提前退出
    }
  }
}

由代码可以看出:

  • 冒泡排序的冒泡过程只涉及相邻数据交换操作、只需要常量级的临时空间,空间复杂度为O(1),是原地排序;
  • 在冒泡排序中只有交换才能改变两个元素的前后顺序,为保证稳定性、我们在相邻元素相等时不进行交换,所以冒泡排序是稳定的排序算法;
  • 最好情况时间复杂度:数组已经是有序的了,所以只需要进行一次冒泡,O(n);
  • 最坏情况时间复杂度:数组是降序排列的,需要进行n次冒泡,O(n^2);
  • 平均情况时间复杂度:冒泡排序包含两个操作,比较和交换,交换次数是确定的,即为逆序度。最好情况下(逆序度为0)需要交换0次、最坏情况下(逆序度为满有序度)需要交换n*(n-1)/2,所以我们折中取n*(n-1)/4为平均交换次数;而比较操作肯定要比交换操作多,因为最坏也就O(n^2)(上限),所以平均也是O(n^2)。

插入排序

插入排序顾名思义,通过将元素插入数组中它该在的位置来进行排序。

  1. 我们将数组分为两个区间:已排序区和未排序区。初始已排序区只有第一个元素。
  2. 取未排序区的元素,在已排序区找到合适的插入位置将其插入,并保证已排序区内数据一直有序,重复这个过程,直到未排序区中元素为空、排序就结束了。
  3. 在数组中插入一个元素涉及两个操作:比较 和 移动。

代码:

// 插入排序
function insertSort (array) {
  if (!array || array.length <= 1) {
    return;
  }
  let len = array.length;
  // 遍历未排序区
  for (let i = 1; i<len; i++) {
    let val = array[i];
    let j = i-1;
    // 在已排序区查找插入位置
    for (; j>=0; j--) {
      if (array[j] > val) { // 比较 
        array[j+1] = array[j]; // 移动
      } else {
        break;
      }
    }
    array[j+1] = val; // 找到插入位置、插入[1.已排序区未遍历完找到插入位置,break;出来插入;2.以排序区已遍历完、已排序区所有元素右移一位、所以在遍历结束后插入到已排序前第1位]
  }
}

由代码可以看出:

  • 插入排序并不需要额外存储空间,空间复杂度为O(1),即它也是一个原地排序算法;
  • 对于值相同的元素,我们可以选择将后面出现的元素插入到前面出现元素的后面,所以是稳定的;
  • 最好情况时间复杂度:本来就是排好序的,每次只需要比较一个数据就能确定位置,所以是O(n);
  • 最坏情况下时间复杂度:数据是倒序的,每次都需要把已排序区遍历完、插入到已排序区第一个位置,所以是O(n^2);
  • 平均情况下时间复杂度:因为将一个元素插入到一个数组中的平均时间复杂度为O(n)[证明略],需要将n个元素插入到数组中其应该在的位置,所以平均时间复杂度为O(n^2)。

另外,冒泡排序和插入排序不管怎么优化,它们的元素交换/移动(插入排序的移动也是增加有序度的过程)次数是固定的、为逆序度,但是从代码上看,冒泡排序的数据交换要比插入排序的数据移动复杂,冒泡需要3个赋值操作,而插入只需1个移动操作:

// 冒泡排序中数据的交换操作
if (array[j]>array[j+1]) {
  let temp = array[j+1];
  array[j+1] = array[j];
  array[j] = temp;
  flag = true;
}

// 插入排序中数据的移动操作
if (array[j] > val) { // 比较 
  array[j+1] = array[j]; // 移动
} else {
  break;
}

所以虽然都是O(n^2)时间复杂度,如果我们希望把性能优化做的极致,比起冒泡排序、肯定选插入排序(插入排序还有很大优化空间,比如希尔排序)。

选择排序

插入排序每次都需要在已排序区遍历查找插入位置,如果我们只在已排序区末尾插入呢?那就是选择排序。(插入排序重点在插入;而选择排序,顾名思义,重点在选择。)

  1. 与插入排序类似,将数组分为已排序区和未排序区;
  2. 每次从未排序区中找到最小的元素,放到已排序区的末尾。

代码:

// 选择排序
function selectSort(array) {
  if(!array || array.length<=1) {
    return;
  }
  let len = array.length;
  for(let i=0; i<len-1; i++) {
    let min = i; 
    for (let j=i+1; j<len; j++) {
      if (array[j] < array[min]) {
        min = j; // 找到未排序区中的最小元素
      }
    }
    // 将未排序区中的最小元素通过交换方式放入已排序区末尾
    let temp = array[i];
    array[i] = array[min];
    array[min] = temp;
  }
}

由代码可以看出:

  • 选择排序是原地排序,空间复杂度为O(1);
  • 选择排序主要操作时查找操作,无论最好情况、最坏情况,每次选择都需要在未排序区内遍历每个元素以查找最小元素,所以其最好、最好、平均情况时间复杂度都为O(n^2)。
  • 插入最小元素是通过跟前面的元素交互位置实现的,这破坏了稳定性。所以选择排序是一种不稳定的排序。

归并排序

  1. 归并排序用到了分治思想;
  2. 把要排序数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起(不是单纯合并,而是有序合并),这样整个数组就有序了。

代码:

// 归并排序
function mergeSort(array) {
  if (!array || array.length<=1) {
    return;
  }
  sort(array, 0, array.length-1);
}
function sort (array, left, right) {
  if (left > right) {
    return;
  }
  let mid = Math.floor((left + right)/2);
  merge(sort(array, left, mid), sort(array, mid+1, right))
}
function merge (array1, array2) {
  let len1 = array1.length,
      len2 = array2.length;
  let result = new Array(len1+len2);
  let i = j = 0;
  while (i<=len1 && j<=len2) {
    if (array1[i] <= array[j]) {
      result.push(array1[i]);
      i++;
    } else {
      result.push(array2[j]);
      j++;
    }
  }
  while (i<=len1) {
    result.push(array1[i]);
    i++;
  }
  while (j<=len2) {
    result.push(array2[j]);
    j++;
  }
  return result;
}

由代码可以看出:

  • 归并排序稳不稳定要看merge()函数,代码中当左右数组存在相同值时、先把左数组的值放入合并的数组,这样保证了稳定性,所以归并排序是一个稳定的排序算法;

  • 归并排序时间复杂度 = 子问题时间复杂度 * 2 + 合并的时间复杂度
    根据上面代码递推公式我们可以得到时间复杂度递推公式:

    T1 = C; n=1时只需要常量级的执行时间
    Tn = 2 * T(n/2) + n; n >1

    所以:

    Tn = 2 * T(n/2) + n
    	 = 2 * (2 * T(n/4) + n/2) + n = 4 * T(n/4) + 2n
    	 = 2 * (2 * (2 * T(n/8) + n/4) + n/2) + n = 8 * T(n/8) + 3n
    	 = ...
    	 = 2^k * T(n/2^k) + k * n
    	 = ...

    当T(n/2^k) = T(1)时 => n/2^k = 1 => 2^k = n => k = log2(n)。

    根据递归终止条件:T1 = C,Tn = n * C + n * log2(n)。

    所以用大O表示法,Tn就等于O(nlogn),即归并排序的时间复杂度是O(nlogn)。

  • 我们从原理和代码可以看出,归并排序的执行效率与要排序的原始数组的有序程序无关,所以其最好、最坏、平均情况时间复杂度都是O(nlogn)。

  • 但是归并排序有一个致命弱点,那就是它不是原地排序的,它的空间复杂度是O(n)。[尽管每次合并都需要申请额外空间,但是在合并操作结束后,临时开辟的控件就被释放掉了,所以临时空间最多也就是n。]

快速排序

  1. 快速排序用到了分治思想;
  2. 快排思想是如果要排序数组中下标从p到r之间的一组数据,选择p、r之间任意一个数作为分区点pivot;遍历p到r之间的数据,将小于pivot的放左边、大于pivot的放右边,pivot放中间;如此一来,p到r之间的数据被分成了3部分:小于pivot的,pivot,大于pivot的;然后再对小于pivot的区域和大于pivot的区域进行快排;直到区间缩小为1、那么就排好序了。

代码:

// 快速排序
function quickSort (array) {
  if (!array || array.length<=1) {
    return;
  }
  sort(array, 0, array.length-1);
}
function sort(array, left, right) {
  // 区间缩小至区间内只有1个元素、则该区间已经排好序
  if (left >= right) {
    return;
  }
  let pivot = partition(array, left, right); // 获取分区点
  sort(array, left, pivot-1);
  sort(array, pivot+1, right)
}
function partition (array, left, right) {
  // 为了实现原地排序算法,采用类似选择排序的 从“未处理区”取出小于pivot的元素插入到“已处理区”末尾,通过交换方式
  // 这里选取最后一个元素为pivot
  let pivot = array[right];
  let i = left; // 标记”已处理区“尾部
  for (let j=left; j<=right-1; j++) { // 注意:从left到right-1找小于pivot的元素、将其放到左边“已处理区”
    if (array[j]<pivot) {
      let temp = array[i];
      array[i] = array[j];
      array[j] = temp;
      i++;
    }
  }
  // 将pivot放到中间
  let temp = array[i];
  array[i] = array[right];
  array[right] = temp;
  // 返回分区点
  return i;
}

注意:上述代码中"已处理区"并不是真的如插入、选择排序一样的已排好序的区域,而是为了更好理解分区操作中跟选择排序类似的通过交换方式、从“未处理区”取出小于pivot的元素插入到“已处理区”末尾

由代码可以看出:

  • 快排是一种原地排序算法(分区操作是原地排序的,其空间复杂度为O(1),但是递归排序过程会占用内存栈空间O(logn),所以其空间复杂度是O(logn)【这点有点疑问,还需再找资料看下】),但是它不是稳定的;
  • 快排时间复杂度最好和平均情况都是O(nlogn),但是在极端情况下——比如倒序排序,快排会退化成冒泡,时间复杂度是O(n^2)。
  • 我们可以通过合理地选择pivot来必买快排算法时间复杂度退化到O(n^2)。
    • 三数取中法(在数组比较大时,可能需要"五数取中"甚至"十数取中");
    • 随机法:每次从要排序区随机取一个元素作为分区点;

另外,

  1. 归并排序和快排都用到了分治思想、都用了递归,它们的时间复杂度都是O(nlogn),那它们的区别是什么呢?

    可以发现,归并排序排序处理过程是从下到上的、先处理子问题再合并;快排是从上到下的、先分区再处理子问题。
    归并是非原地排序算法(它之所以是非原地排序算法,注意是因为合并函数无法在原地执行);快排通过设计巧妙的原地分区函数,实现原地排序,解决了归并排序占用太多内存的问题。
  2. 对于javascript的数组sort,不同引擎可能实现方式不同:
    • Mozilla/Firefox:归并排序(jsarray.c 源码
    • V8:数组长度小于等于22时用插入排序,否则用快排(array.js源码,第710行 InnerArraySort、第760行 QuickSort)

桶排序

桶排序顾名思义,"桶"是用来排序的主角。

  1. 将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序;
  2. 桶内排序好之后,把每个桶里的数据按顺序取出组合,得到的就是排好序的数据。

  1. 桶排序时间复杂度是O(n)。
    如果有n个数据,我们把他们均匀分发到m个桶内,那么每个桶就有k = n/m个数据,对每个桶里的数据进行快排,则每个桶的排序时间复杂度是O(klogk),m个桶的排序时间复杂度就是O(m * klogk) = O(nlog(n/m))。当桶的个数m接近要排序的数据个数n时,桶排序时间复杂度就接近O(n)。
  2. 桶排序对要排序的数据的要求很严格:
    • 要排序的数据能很容易地划分到m个桶里,桶和桶之间有天然的大小顺序,这样每个桶内排序好后,桶与桶之间不用再排序;
    • 数据在每个桶里需要分别均匀,否则有些桶里数据很多、有些桶里数据很少,那桶内的排序的时间复杂度会退化。比如极端情况下所有数据被划分到一个桶里了,那它的时间复杂度取决于桶内采用的排序算法,比如采用快排、那就变成完全的快排了,时间复杂度是O(nlogn);如果采用插入排序、那就变成完全的插入排序了,时间复杂度是O(n^2)。

计数排序

计数排序其实是桶排序的一种特殊情况。

  1. 当要排序的n个数据,它们的值所处的范围并不大时,我们可以按这个值进行分桶。比如最小值是1、最大值时k,我们可以把数据划分成k个桶。每个桶里放的值都是相同的,所以不必再进行桶内排序。

基数排序

  1. 要求稳定;
  2. 对每一位进行排序,可以用桶排序或计数排序。假设要排序的数据有k位,就需要进行k次桶排序或计数排序,时间复杂度是O(k*n),当k不大时,比如对手机号码进行排序,k为11,那么基数排序的时间复杂度可以认为是O(n)。

总结

  1. 按是否基于比较划分:
    • 基于比较的:冒泡、插入、选择、归并、快排
    • 不基于比较的:桶排序、计数排序、基数排序
  2. 按时间复杂度划分:
    • 时间复杂度为O(n^2)的有:
      • 冒泡
      • 插入
      • 选择
    • 时间复杂度为O(nlogn)的有:
      • 归并
      • 快排
    • 时间复杂度为O(n)的有:
      • 桶排序
      • 计数排序
      • 基数排序
  3. 按稳定性划分:
    • 稳定的有:冒泡、插入、
    • 不稳定的有:选择
  4. 按是否原地排序划分:
    • 原地排序:冒泡、插入、选择、快排、
    • 非原地排序:归并、桶、计数、基数
排序算法 最好情况时间复杂度 最坏情况时间复杂度 平均情况时间复杂度 空间复杂度 是否原地排序 稳定性
冒泡排序 O(n) O(n^2) O(n^2) O(1) 稳定
插入排序 O(n) O(n^2) O(n^2) O(1) 稳定
选择排序 O(n^2) O(n^2) O(n^2) O(1) 不稳定
归并排序 O(nlogn) O(nlogn) O(nlogn) O(n) 稳定
快速排序 O(nlogn) O(n^2) O(nlogn) O(logn) 不稳定
桶排序 O(n) O(nlog)或O(n^2),取决于桶内排序算法 O(n) O(n+k)[?有疑问] 稳定
计数排序 O(n + k) | k是数据范围 O(n + k) O(n) O(k) 稳定
基数排序 O(dn)|d是维度 O(dn) O(dn) O(n + d)[?有疑问] 稳定

另外经典排序还有希尔排序、堆排序等,下次有空再整理把。

参考

  1. https://mp.weixin.qq.com/s/7CpTtHDIOTB6MyPXoHrLfQ
  2. https://time.geekbang.org/column/article/42359
@FridaS FridaS added 计算机基础 计算机基础知识 算法 labels Mar 16, 2019
@FridaS FridaS mentioned this issue Mar 16, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
算法 计算机基础 计算机基础知识
Projects
None yet
Development

No branches or pull requests

1 participant