数据结构与算法之排序大全

数据结构与算法之排序

排序算法有很多,常用的有冒泡排序,插入排序,选择排序,归并排序,快速排序,计数排序,基数排序,桶排序。我们按照事件复杂度可以将其分为3类。
在这里插入图片描述

如何分析一个“排序算法”?

1. 排序算法的执行效率

  • 最好情况,最坏情况,,平均情况事件复杂度
    我们在分析排序算法的时间复杂度,要分别给出最好情况,最坏情况,平均情况下的时间复杂度。除此之外,还要知道最好,最坏时间复杂度对应的要排序的原始数据是什么样的。
    为什么要区分这三种复杂度呢?第一,有些排序算法会区分,为了好对比,所以我们最好都做一下区分。第二,对于要排序的数据,有点接近有序,有的完全无序。有序度不同的数据,对于排序的执行时间肯定有影响的,我们要知道排序算法在不同数据下的性能表现。
  • 时间复杂度的系数,常数,低阶
    因为在实际开发过程中,我们排序的可能是小规模的数据。
  • 比较次数和交换(或移动)次数

2. 排序算法的内存消耗
针对排序算法的空间复杂度,我们引入了一个新的概念,原地排序。原地排序算法,就是特质空间复杂度是O(1)的排序算法。

3. 排序算法的稳定性
针对排序算法,我们还有一个重要的度量指标,稳定性。这个概念是说,如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。

冒泡排序

冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻对两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在对位置,重复n次,就完成了n个数据的排序工作。
在这里插入图片描述

// 冒泡排序,a 表示数组,n 表示数组大小
public void bubbleSort(int[] a, int n) {
  if (n <= 1) return;
 
 for (int i = 0; i < n; ++i) {
    // 提前退出冒泡循环的标志位
    boolean flag = false;
    for (int j = 0; j < n - i - 1; ++j) {
      if (a[j] > a[j+1]) { // 交换
        int tmp = a[j];
        a[j] = a[j+1];
        a[j+1] = tmp;
        flag = true;  // 表示有数据交换      
      }
    }
    if (!flag) break;  // 没有数据交换,提前退出
  }
}

问题:

  1. 冒泡排序是元素排序算法么?
    冒泡的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为O(1),是一个元素排序算法。
  2. 冒泡排序是稳定的排序算法么》
    是的
  3. 冒泡排序的时间复杂度是多少?
    最好:O(1)
    最坏:O(n^2)
    平均:通过有序度和逆序度来分析

插入排序(Insertion Sort)

首先,我们将数组中的数据分为两个区间,已排区间和未排区间。初始已排区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排区间数据一直有序。重复这个过程,直到未排序区间中元素为空。
在这里插入图片描述

// 插入排序,a 表示数组,n 表示数组大小
public void insertionSort(int[] a, int n) {
  if (n <= 1) return;

  for (int i = 1; i < n; ++i) {
    int value = a[i];
    int j = i - 1;
    // 查找插入的位置
    for (; j >= 0; --j) {
      if (a[j] > value) {
        a[j+1] = a[j];  // 数据移动
      } else {
        break;
      }
    }
    a[j+1] = value; // 插入数据
  }
}

问题:

  1. 插入排序是原地排序算法么?
  2. 插入排序是稳定的排序算法么?
  3. 插入排序的时间复杂度是多少?
    最好:O(n)
    最坏:O(n^2)
    平均:在数组中插入一个数据的平均时间复杂度是O(n)。所以,对于插入排序来说,每次插入操作都相当于在数组中插入一个元素,循环执行n次插入操作,所以平均时间复杂度为O(n^2)

选择排序

类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。
在这里插入图片描述

归并排序

如果要排序一个数组,我们先把数组从中间分成了前后两部分,然后对前后两部分分别排序,再将排好序对两部分合并在一起,这样整个数组就都有序了。
在这里插入图片描述
归并排序使用的就是分治思想。分治,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。

其实分治思想和递归思想很像。分治算法一般都是用递归来实现的。分治是一种解决问题的处理思想,递归是一种编程技巧,这两者并不冲突。

接下来我们就用递归代码来实现归并排序:
第一步:分析得出递推公式

递推公式:
merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r))

第二步:找到终止条件

终止条件:
p >= r 不用再继续分解

第三步:将递推共识翻译成递归代码

// 归并排序算法, A 是数组,n 表示数组大小
merge_sort(A, n) {
  merge_sort_c(A, 0, n-1)
}

// 递归调用函数
merge_sort_c(A, p, r) {
  // 递归终止条件
  if p >= r  then return

  // 取 p 到 r 之间的中间位置 q
  q = (p+r) / 2
  // 分治递归
  merge_sort_c(A, p, q)
  merge_sort_c(A, q+1, r)
  // 将 A[p...q] 和 A[q+1...r] 合并为 A[p...r]
  merge(A[p...r], A[p...q], A[q+1...r])
}

其中merge(A[p…r],A[p…q],A[q+1…r])这个函数的作用就是,将已经有序的A[p…q]和A[q+1…r]合并成一个有序的数组,并且放入A[p…r]。具体过程如下:

我们申请一个临时数组tmp,大小与A[p…r]相同。我们用两个游标i和j,分别指向A[p…q]和A[q+1…r]的第一个元素。比较这两个元素A[i]和A[j],如果A[i] <= A[j],我们就把A[i]放入到临时数组tmp,并且i向后移一位,否则将A[j]放入到数组tmp,j后移一位。

继续上述比较过程,知道其中一个子数组中的所有数据都放入临时数组中,再把另一个数组中的数据依次加入到数组的末尾,这个时候,临时数组中存储的就是两个子数组合并之后的结果了。最后再把临时数组tmp中的数据拷贝到愿数组A[p…r]中。

merge(A[p...r], A[p...q], A[q+1...r]) {
  var i := p,j := q+1,k := 0 // 初始化变量 i, j, k
  var tmp := new array[0...r-p] // 申请一个大小跟 A[p...r] 一样的临时数组
  while i<=q AND j<=r do {
    if A[i] <= A[j] {
      tmp[k++] = A[i++] // i++ 等于 i:=i+1
    } else {
      tmp[k++] = A[j++]
    }
  }
  
  // 判断哪个子数组中有剩余的数据
  var start := i,end := q
  if j<=r then start := j, end:=r
  
  // 将剩余的数据拷贝到临时数组 tmp
  while start <= end do {
    tmp[k++] = A[start++]
  }
  
  // 将 tmp 中的数组拷贝回 A[p...r]
  for i:=0 to r-p do {
    A[p+i] = tmp[i]
  }
}

问题:

  1. 插入排序是原地排序算法么?
    不是
  2. 插入排序是稳定的排序算法么?
  3. 插入排序的时间复杂度是多少?
    对于递归的时间复杂度,我们可以写出归并排序的时间复杂度计算公式:
T(1) = C;   n=1 时,只需要常量级的执行时间,所以表示为 C。
T(n) = 2*T(n/2) + n; n>1

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

==========》 T(n)=2^kT(n/2^k) + kn。当T(n/2^k)=T(1) 时,也就是 n/2^k=1,我们得到k=log2n,们将 k 值代入上面的公式,得到 T(n)=Cn+nlog2n ====》nlogn

快速排序

如果要排序数组中下标从p到r之间的一组数据,我们选择p到r之间的任意一个数据作为pivot(分区点)。

我们遍历p到r之间的数据,将小于pivot的放到左边,将大于pivot的放到右边,将pivot放到中间。经过这一步骤之后,数组p到r之间的数据就被分成了三个部分,前面p到q-1之间都是小于pivot的,中间是pivot,后面的q+1到r之间是大于pivot的。

在这里插入图片描述
根据分治,递归的处理思想,我们可以用递归排序下标从p到q-1之间的数据和下标从q+1到r之间的数据,知道区间缩小为1
,就说明所有的数据都有序了。

如果我们用递推公式来将上面的过程写出来的话,就是这样:

递推公式:
quick_sort(p…r) = quick_sort(p…q-1) + quick_sort(q+1, r)
终止条件:
p >= r

我将递推公式转化成递归代码:

// 快速排序,A 是数组,n 表示数组的大小
quick_sort(A, n) {
  quick_sort_c(A, 0, n-1)
}
// 快速排序递归函数,p,r 为下标
quick_sort_c(A, p, r) {
  if p >= r then return
  
  q = partition(A, p, r) // 获取分区点
  quick_sort_c(A, p, q-1)
  quick_sort_c(A, q+1, r)
}

非原地分区函数实现思路:
在这里插入图片描述
原地分区函数实现思路:

partition(A, p, r) {
  pivot := A[r]
  i := p
  for j := p to r-1 do {
    if A[j] < pivot {
      swap A[i] with A[j]
      i := i+1
    }
  }
  swap A[i] with A[r]
  return i

在这里插入图片描述
问题:

  1. 插入排序是原地排序算法么?
  2. 插入排序是稳定的排序算法么?
    不是
  3. 插入排序的时间复杂度是多少?
    O(nlogn)
T(1) = C;   n=1 时,只需要常量级的执行时间,所以表示为 C。
T(n) = 2*T(n/2) + n; n>1

但是,公式成立的前提是每次分区操作,我们选择的pivot都很合适,正好能将大区间对等地一分为二。但实际上这种情况是很难实现的。在极端情况下,时间复杂度甚至会从O(nlogn)退化成O(n^2)。

那快排的平均复杂度是多少呢?我们假设每次分区操作都将区间分成大小为9:1的两个小区间。我们继续套用递归时间复杂度的递推公式,就会变成这样:

T(1) = C;   n=1 时,只需要常量级的执行时间,所以表示为 C。
T(n) = T(n/10) + T(9*n/10) + n; n>1

在大多数情况下,时间复杂度都可以达到O(nlogn),只有在极端情况下才会退化到O(n^2)。

桶排序

顾名思义,桶排序就是将数据放在一个个桶中,然后对每个桶进行单独排序,最后合并起来成为一个有序的列表。
使用场景:适用于在特定数据中,将每个桶内的数据合并起来之后不需要再进行排序。
比如在磁盘中存有10G数据,无法一次性读入内存,这时则需要将这些数据按照一定情况进行分组,再进行组内排序。
在这里插入图片描述

计数排序

计数排序其实是桶排序的一种特殊情况,例如有n个数在区间0-k之间(n小于等于k),我们就可以将这n个数放到k个桶中排序。

基数排序

基数排序对要排序的数据是有要求的,需要可以分割出独立的“位”来比较,而且位之间有递进的关系,如果 a 数据的高位比 b 数据大,那剩下的低位就不用比较了。除此之外,每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到 O(n) 了。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值