引子:
- 为什么要学习排序算法?
排序是程序员们最早接触的一类算法,也是大部分面试必考技能:手写一个简单的常见排序算法,给出时间复杂度(最好,最坏,平均);简单点的让你口述下算法原理等等。之所以这么着重考察,我的经验是一来实际软件很多场景中排序是经常用到的算法;二来排序可以考察基本的算法能力–执行效率、内存消耗、稳定性,分治和归并思想等等,常见排序算法虽简单,但是要深入理解他们,这些分析方法和思想都是需要的。 - 时间复杂度一样,为什么实际开发中,插入排序比冒泡排序更受欢迎?
算法的原理、代码实现固然重要,但更重要的是如何评价和分析算法,这种更通用、可迁移的能力才是我们更看中更受用的。首先介绍排序算法的分析方法,然后分别对冒泡、插入、排序这几大算法进行分析。
一. 如何分析排序算法
1. 执行效率(时间)
- 最好、最坏、平均情况时间复杂度
一是有些算法需要,方便各类算法的对比,统一起见都做;
二是原始数据的有序性对执行效率有影响。
可使用
有序度
来估算算法平均情况时间复杂度。
有序元素对:a[i] <= a[j], 如果 i < j 。
有序度:数组中有序元素对的个数。例如:1,5,2的有序度是2(共2个有序对(1, 5), (1, 2))
满有序度:整个数组完全有序时(按顺序(从小到大)依次排列)的有序度,且满有序度计算公式为n*(n-1)/2 。例如:1,3,5,6属于完全有序,满有序度为6((1,3), (1,5), (1,6), (3,5), (3,6), (5,6))。
逆序度:与有序度相反,指的是数组中逆序元素度的个数,逆序度 = 满有序度 - 有序度。
排序算法是一个提升有序度降低逆序度,最终达到满有序度的过程。
有序度可以用来快速估算算法的时间复杂度,正确性可以满足大部分分析需求。
- 时间复杂度的系数,低阶,常数
一般我们考虑算法的时间复杂度,指的是数据规模很大时算法执行时间的一个增量趋势,这个时候系数、常数、低阶是可以忽略不计的。但是实际中也是存在很多数据规模小的应用场景的,此时,对时间复杂度的排序算法就需要考虑其系数、常数、低阶。 - 比较和交换(或移动)次数
2. 内存消耗(空间)
通过判断一个排序算法是否是原地排序来简单估计他的内存消耗。
冒泡,插入,排序都属于原地排序。
原地排序(stored in place),指的是空间复杂度为O(1)的排序算法。
3. 稳定性
相等元素经排序后保持原有相对顺序不变,则为稳定排序,否则非稳定排序。
原因:实际的排序对象不是简单的整数,而是复杂的数据对象,我们往往根据其中某几个key排序,此时就有了稳定排序的需求。
我的思考回答:实际的问题和业务场景中,待排序的对象数据单元往往不是单个的整数这么简单(我们学习算法的时候常用整数来举例说明),而是由一组复杂的数据构成,也就是说一个对象数据单元存在多个数据属性值,我们需要根据对象数据的不同属性值来对一批对象分别进行排序。在对数据对象根据某一属性值排序后,我们期望对这批数据再按照另外属性值排序时,是在之前属性值排序的基础上进行的,也就是说后来的排序不能打乱前面属性值的排序结果。比如,某宝某东上面一个商家某段时间内商品的销售情况,我们会有价格,时间,销量等属性,我们先按时间排序,然后按销量排序,以此得到按时间线销量的变化情况,我们期望的是后面销量排序的时间线依然是前面的结果–保持稳定。
二. 冒泡排序
- 算法原理
每一轮冒泡过程,都是按照顺序依次对相邻两个元素进行比较,看是否符合要求,不符合则互换位置,符合则保持不变。一轮冒泡至少让一个元素移动到它应该在的位置。重复N轮,就完成了N个数据的排序。
- 算法实现
void bubble_sort(int *array, int n) //ascending
{
int tmp;
bool swap_flag = false; // data swap or not
if (n <= 1) return;
for(int i = 0; i < n; ++i)
{
swap_flag = false;
//for(int j = 0; j < n; ++j) //calm: Bug: last one array[n-1] will be swap to 0(array[n])
//for(int j = 0; j < n-1; ++j) //calm: optimize
for(int j = 0; j < n-i-1; ++j)
{
if(array[j] > array[j+1])
{
tmp = array[j+1];
array[j+1] = array[j];
array[j] = tmp;
swap_flag = true;
}
}
if(swap_flag == false)
break;
}
return;
}
- 几点注意:
1)优化:如果某轮冒泡没有发生数据交换,则说明数据已经达到有序,可提前结束冒泡操作。对应代码中swap_flag的功能。
2)优化:内层for循环的次数可以从j < n-1优化成j < n-i-1
3)在编码中遇到一个bug,就是内层for循环循环次数应该是j<n-1,而非j<n,否则导致排序后的最后一个元素array[n-1]被交换成数组越界访问的元素array[n],导致值为0。
- 算法指标
最好时间复杂度O(n),最坏时间复杂度O(n2) ,平均时间复杂度O(n2)
满有序度n(n-1)/2,平均有序度n(n-1)/4,所以平均时间复杂度也是O(n2)
原地排序;
稳定排序;
三. 插入排序
- 算法原理
数据分为已排序区和未排序区,初始已排区为数组第一个元素;每次依次从未排序区取出一个元素,插入到已排区合适位置;重复这个过程知道未排序区元素为空,排序结束。
- 算法实现
void insertion_sort(int *array, int n) //ascending
{
int tmp;
if (n <= 1) return;
/* Two data part:
* i : unsorted data part
* j : sorted data part
*/
//for(int i = 1; i < n-1; ++i) //calm: Bug: the last element(array[n-1]) will not to be sorted
for(int i = 1; i < n; ++i)
{
int value = array[i];
int j = i-1;
for(; j >= 0; --j) //find position to insert data "value"
{
if(array[j] > value)
{
array[j+1] = array[j]; // move data
}else{
break;
}
}
array[j+1] = value; //insert data
}
return;
}
- 算法指标
最好时间复杂度O(n),最坏时间复杂度O(n2),平均时间复杂度O(n2)
数组中插入一个元素的平均时间复杂度是O(n),循环n次,故插入算法平均时间复杂度为O(n2)
原地排序;
稳定排序;
四. 选择排序
- 算法原理
基本原理与插入排序一样,不同的是,每次是先从未排区选择最小的元素插入到已排区。
- 算法指标
最好时间复杂度O(n2),最坏时间复杂度O(n2),平均时间复杂度O(n2);
原地排序;
非稳定排序。
五. 总结
算法\指标 | 最好时间复杂度 | 最坏时间复杂度 | 平均时间复杂度 | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | O(n) | O(n2) | O(n2) | 原地 | 稳定 |
插入排序 | O(n) | O(n2) | O(n2) | 原地 | 稳定 |
选择排序 | O(n2) | O(n2) | O(n2) | 原地 | 不稳定 |
答疑:时间复杂度相同,为什么实际开发中,插入排序比冒泡排序更受欢迎?
冒泡排序和插入排序无论怎么优化,元素交换的次数都是固定的 ---- 原始数据的逆序度。从两种算法的代码实现可以看到,数据交换时它们的赋值操作不一样 ---- 冒泡3次赋值操作,插入1次赋值操作,所以在数据集一定的情况下,插入排序比冒泡排序性能更优。
//每次数据交换时,冒泡排序需要 3 次赋值操作
if(array[j] > array[j+1])
{
tmp = array[j+1];
array[j+1] = array[j];
array[j] = tmp;
swap_flag = true;
}
//每次数据交换时,插入排序只要 1 次赋值操作
if(array[j] > value)
{
array[j+1] = array[j]; // move data
}else{
break;
}