在平时的项目中,我们遇到最多的算法应该就是排序了。其中最经典、最常用的算法有:冒泡排序、插入排序、选择排序、快速排序、归并排序、基数排序等。
1. 评判排序算法的标准
排序算法有很多种,那么我们该如何评判一个排序算法呢?一般情况下,我们可以从排序算法的执行效率、排序算法的内存消耗和排序算法的稳定性去考虑。
1.1 执行效率
1. 最好情况、最坏情况、平均情况时间复杂度
在要排序的数据中,执行效率与原始数据是否有序有关,有的原始数据接近有序,有的原始数据完全无序,对于有序度不同的数据,去排序执行的时间肯定有影响,因此我们有必要知道最好情况、最坏情况和平均情况时间复杂度下的执行效率。
2. 时间复杂度的系数、常数、低阶
通过前面的学习,我们知道:时间复杂度反应的是数据规模n趋向无穷大时的一个增长趋势,在理想情况下我们会忽略系数、常数、低阶。但在实际开发过程中,我们排序的可能是10个、100个、1000个这样规模很小的数据,所以,在对同一阶时间复杂度的排序算法性能对比的时候,我们需要将系数、常数、低阶也考虑进去。
3. 比较次数和交换(或移动)次数
在基于比较排序算法的执行过程中,一般是进行比较元素大小或元素交换或移动。因此我们在分析排序算法的执行效率的时候,应该将比较次数和交换(移动)次数考虑进去。
1.2 内存消耗
算法的内存消耗可以用空间复杂度来衡量。在排序算法的空间复杂度中,有一个概念叫"原地排序":是指空间复杂度为O(1)的排序算法。
1.3 稳定性
排序算法中的稳定性是指:在待排序的数据中存在相等的元素,经过排序后,相等元素之间原有的先后顺序不变。
例如有如下一组数据:
2 6 3 8 9 3 7
在上面的数据中有两个3。
经过某种排序算法之后,如果两个3的前后顺序没有改变,那么这个排序算法就叫做"稳定的排序算法";如果前后顺序发生变化,那么对应的排序算法就叫做"不稳定的排序算法"。
2. 冒泡排序
-
冒泡排序操作的是相邻的两个数据
-
每次冒泡操作都会对相邻的两个元素进行比较,看看是否满足大小关系要求,如果不满足就让它俩互换
-
一次冒泡会让至少一个元素移动到它应该在的位置,重复n次,就完成了n个数据的排序工作
举例说明:
为了更好的理解冒泡排序,这里我们举一个例子:对4,5,6,3,2,1这六个数字从小到大进行排序。
第一次冒泡操作的详细过程如下:
通过上面的照片我们可以看到:经过第一次冒泡操作后,6这个元素已经存储在正确的位置上了。
如果想要完成全部数据的排序,那么我们就需要进行六次这样的冒泡操作,每次冒泡操作后的如下图所示:
实际上,在上面冒泡的过程中可以进行优化:当某次冒泡操作已经没有数据交换时,说明已经达到完全有序了,就不用在继续执行后续的冒泡操作。
举例说明:
这里给定6个元素,只需要进行4次冒泡操作就可以了,如下图所示:
示例代码:
结合前面的分析,我们可以得出冒泡排序的代码实现:
// 冒泡排序,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(n)。
最坏情况:
如果要排序的数据刚好是倒序的,那么我们就需要进行n次冒泡操作,这是最坏情况,此时的时间复杂度是O(n^2)。
平均情况:
平均时间复杂度就是加权平均期望时间复杂度,分析的时候要结合概率论的知识。
对于包含n个数据的数组,这n个数据就有n阶乘中排列方式。不同的排列方式,冒泡排序执行的时间肯定不同。
如果用概率论方法定量分析平均时间复杂度,涉及到数学推理和计算就会很复杂。不过有另外一种分析思路,通过“有序度”和“逆序度”这两个概念来分析。
有序度是数组中具有有序关系的元素对的个数。
数学表达式如下:
有序元素对:a[i] <= a[j], 如果 i < j。
举例说明:
同理,对于一个倒序排列的数组,比如6,5,4,3,2,1,有序度就是0;对于一个完全有序的数组,比如1,2,3,4,5,6,有序度就是n*(n-1)/2,也就是15。我们把这种完全有序的数组的有序度叫作 满有序度。
**逆序度:**是数组中不具有有序关系的元素对的个数。
逆序度的定义正好跟有序度相反(默认从小到大为有序),数学表达式如下:
逆序元素对:a[i] > a[j], 如果 i < j。
上面讲了有序度、满有序度和逆序度的概念,它们之间有如下关系:
逆序度 = 满有序度 - 有序度
其实,我们在排序的过程中就是一种增加有序度,减少逆有序度的过程,最后达到满有序度,就说明排序完成了。
继续前面冒泡排序的例子来说明:要排序的数组的初始状态是4,5,6,3,2,1,其中有序元素对有(4,5)(4,6)(5,6),所以有序度是3。而n=6,所以排序完成之后终态的满有序度为n*(n-1)/2=15。
冒泡排序包含两个操作原子,比较和交换。
每交换一次,有序度就加1。不管算法怎么改进,交换次数总是确定的,即为逆序度,也就是n*(n-1)/2 - 初始有序度。上面的例子就是15-3=12,即要进行12次交换操作。
对于包含n个数据的数组进行冒泡排序:
最坏情况下,初始状态的有序度是0,所以要进行n*(n-1)/2次交换;
最好情况下,初始状态的有序度是n*(n-1)/2,就不需要进行交换。
这里我们可以取个中间值n*(n-1)/4,来表示初始有序度既不是很高也不是很低的平均情况。此时就需要n*(n-1)/4次交换操作,比较操作肯定要比交换操作多,而复杂度的上限是O(n2),所以平均情况下的时间复杂度就是O(n2)。
这种计算平均复杂度的过程并不严格,但相比于概率论的定量分析还是比较实用的!
2. 冒泡排序的内存消耗如何?
冒泡过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为O(1),是一个原地排序算法。
3. 冒泡排序稳定吗?
在冒泡排序中,只有交换才可以改变两个元素的前后顺序。为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,我们不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法。
3. 插入排序
在插入排序中,我们将数组中的数据分为两个区间:已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组中的第一个元素。
插入排序算法的核心思想就是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。
举例说明:
要排序的初始数据是4,5,6,1,3,2,其中左侧为排序区间,右侧是未排序区间。
插入排序主要包含元素的比较和元素的移动两种操作。当我们需要将一个数据a插入到已排序区间时,需要将a与已经排序区间的元素依次比较大小,找到合适的插入位置。找到插入点后,还需要将插入点之后的元素顺序往后移动一位,这样才能腾出位置给元素a插入。
对于不同的查找插入点方法(从头到尾、从尾到头),元素的比较次数是有区别的。但对于一个给定的初始序列,移动操作的次数是固定的,等于逆序度。
示例代码:
// 插入排序,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. 插入排序的时间复杂度?
最好情况:
如果要排序的数据已经是有序的,那么我们就不需要移动任何数据。
如果在从尾到头的有序数据中找插入位置,每次只需要比较一个数据就能确定插入的位置,这是最好的情况,此时的时间复杂度是O(n)。
最坏情况:
如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,这是最坏的情况,此时的时间复杂度是O(n^2)。
平均情况:
前面我们说了在数组中插入一个数据的平均时间复杂度是O(n),所以,对于插入排序来说,每次插入操作相当于在数组中插入一个数据,循环执行n次插入操作,所以平均情况的时间复杂度是O(n^2)。
2. 插入排序的内存消耗如何?
从上面的示例代码可以看出,插入排序算法的运行并不需要额外的存储空间,所以空间复杂度是O(1),即插入排序是一个原地排序算法。
3. 插入排序稳定吗?
在插入排序中,对于值相同的元素,可以选择将后面出现的元素插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变,所以插入排序是稳定的排序算法。
4. 选择排序
选择排序算法的实现思路也分已排序区间和未排序区间。选择排序是每次都会从未排序区间中找到最小的元素,然后再将其放到已排序区间的末尾。
举例说明:
要排序的初始数据是4,5,6,3,2,1,其中默认第一个数为排序区间,右侧其它的是未排序区间。
示例代码:
// 选择排序,a表示数组,n表示数组大小
public static void selectionSort(int[] a, int n) {
if (n <= 1) return;
for (int i = 0; i < n - 1; ++i) {
// 查找最小值
int minIndex = i;
for (int j = i + 1; j < n; ++j) {
if (a[j] < a[minIndex]) {
minIndex = j;
}
}
// 交换
int tmp = a[i];
a[i] = a[minIndex];
a[minIndex] = tmp;
}
}
结合前面的三个算法评判标准来看看插入排序:
1. 选择排序的时间复杂度?
选择排序的最好情况、最坏情况和平均情况的时间复杂度都是O(n^2)。
2. 选择排序的内存消耗如何?
选择排序的运行并不需要额外的存储空间,所以空间复杂度是O(1),即选择排序是一个原地排序算法。
3. 选择排序稳定吗?
选择排序每次都要从未排序元素中找最小值,并和前面的元素交换位置,这样就是破坏了稳定性。
例如5,8,5,2,9这样一组数据,使用选择排序算法排序的时候,第一次找到最小元素2,与第一个5交换位置,那第一个5和中间的5顺序就变了。所以选择排序不稳定。