数据结构与算法之排序
排序算法有很多,常用的有冒泡排序,插入排序,选择排序,归并排序,快速排序,计数排序,基数排序,桶排序。我们按照事件复杂度可以将其分为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; // 没有数据交换,提前退出
}
}
问题:
- 冒泡排序是元素排序算法么?
冒泡的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为O(1),是一个元素排序算法。 - 冒泡排序是稳定的排序算法么》
是的 - 冒泡排序的时间复杂度是多少?
最好: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; // 插入数据
}
}
问题:
- 插入排序是原地排序算法么?
是 - 插入排序是稳定的排序算法么?
是 - 插入排序的时间复杂度是多少?
最好: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]
}
}
问题:
- 插入排序是原地排序算法么?
不是 - 插入排序是稳定的排序算法么?
是 - 插入排序的时间复杂度是多少?
对于递归的时间复杂度,我们可以写出归并排序的时间复杂度计算公式:
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
问题:
- 插入排序是原地排序算法么?
是 - 插入排序是稳定的排序算法么?
不是 - 插入排序的时间复杂度是多少?
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) 了。