数据结构与算法篇 之冒泡,插入,选择排序

排序算法其实有很多,比如猴子排序,睡眠排序,面条排序,听都没听过。。。。。。。

其中最经典的自常用的是:冒泡排序,插入排序,选择排序,归并排序,快速排序,计数排序,基数排序,桶排序

排序算法里面一般第一个接触的就是冒泡排序,然后就是选择排序,插入排序,这几个简单的算法入门的。

现在我们来想一个问题,就是为什么插入排序很冒泡排序的时间复杂度都是O(n^2),但在实际当中我们更倾向于于使用插入排序。

我们会从哪几个方面去衡量排序算法的执行效率呢?

第一个  最好情况,最坏情况,平均情况

有些数据是趋于有序,有的却是趋于无序,有序度的不同对算法排序的时间都有影响

第二个 时间复杂度的系数,常数,低阶

大O表示法可以忽略系数,常熟,低阶是因为当n是一个很大规模的增长趋势,但是在排序10个,100个,1000个的时候低阶就需要考虑进来了

第三个 比较常数和交换(移动)次数

基于比较的排序算法的执行过程,一般都会涉及两种操作。一种是比较大小,一种是数据交换或者移动

算法的内存消耗可以通过空间复杂度来衡量,针对排序算法我们还引入一个新的概念原地排序,原地排序特指空间复杂度为O(1)的排序算法,今天讲的三种排序都是原地排序算法。

排序算法的稳定性:仅仅用执行效率和内存消耗来衡量排序算法的好坏是不够的,针对排序算法我们还有一个重要的度量指标就是稳定性

我通过一个例子来解释一下。比如我们有一组数据 2,9,3,4,8,3,按照大小排序之后就是 2,3,3,4,8,9。

这组数据里有两个 3。经过某种排序算法排序之后,如果两个 3 的前后顺序没有改变,那我们就把这种排序算法叫作稳定的排序算法;如果前后顺序发生变化,那对应的排序算法就叫作不稳定的排序算法。

你可能要问了,两个 3 哪个在前,哪个在后有什么关系啊,稳不稳定又有什么关系呢?为什么要考察排序算法的稳定性呢?

很多数据结构和算法课程,在讲排序的时候,都是用整数来举例,但在真正软件开发中,我们要排序的往往不是单纯的整数,而是一组对象,我们需要按照对象的某个 key 来排序。

比如说,我们现在要给电商交易系统中的“订单”排序。订单有两个属性,一个是下单时间,另一个是订单金额。如果我们现在有 10 万条订单数据,我们希望按照金额从小到大对订单数据排序。对于金额相同的订单,我们希望按照下单时间从早到晚有序。对于这样一个排序需求,我们怎么来做呢?

最先想到的方法是:我们先按照金额对订单数据进行排序,然后,再遍历排序之后的订单数据,对于每个金额相同的小区间再按照下单时间排序。这种排序思路理解起来不难,但是实现起来会很复杂。

借助稳定排序算法,这个问题可以非常简洁地解决。解决思路是这样的:我们先按照下单时间给订单排序,注意是按照下单时间,不是金额。排序完成之后,我们用稳定排序算法,按照订单金额重新排序。两遍排序之后,我们得到的订单数据就是按照金额从小到大排序,金额相同的订单按照下单时间从早到晚排序的。为什么呢?

因为稳定的排序算法可以保证金额相同的两个对象,在排序的前后不变

也就是第一次排序后,所有订单从早到晚有序了,第二次对金额进行排序,保持金额相同的订单从早到晚有序。

讲了一大堆废话,那下面就来实现三种算法

冒泡排序(Bubble Sort)

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

下面两张图就很好说明了冒泡排序的整个过程

冒泡算法可以进行优化的,就是当某一次出现了不需要交换的时候,就有序了。

void BubbleSort(int a[],int n)

{

if(n <= 1) return ;

for(int i = 0;i < n;++i)

{

bool 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] = a[j];

flag = true;

}

}

if(!flag) break;

}

}

冒泡是原地排序算法?是的,很明显只有一个常量级的临时变量O(1)

是稳定排序算法吗?很明显是的,if(a[j] > a[j+1])

时间复杂度是多少?最好的情况是O(n),而最坏的是倒序O(n^2)

那它的平均复杂度是多少呢?这里先引进两个概念分别有序度和逆序度

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

对于一个倒序排列的数组,比如 6,5,4,3,2,1,有序度是 0;对于一个完全有序的数组,比如 1,2,3,4,5,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)。

接下来的插入排序,插入排序其实也是很简单的,也就是一个有序的数组,然后一个个的进行插入,保证插入后的数组是有序的

也就是把一个数组分成有序的部分和无序的部分,取未排序的中的元素插入已排序的数组中,一直到最后未排序的数组没有元素了

插入排序有两种操作,第一种是元素的比较,第二种是元素的插入

对于不同的查找插入点方法(从头到尾、从尾到头),元素的比较次数是有区别的。但对于一个给定的初始序列,移动操作的次数总是固定的,就等于逆序度。

为什么说移动次数就等于逆序度呢?我拿刚才的例子画了一个图表,你一看就明白了。满有序度是 n*(n-1)/2=15,初始序列的有序度是 5,所以逆序度是 10。插入排序中,数据移动的个数总和也等于 10=3+3+4。

代码:

void insertSort(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];//move

else break;

}

a[j+1] = value;//insert

}

}

第一,插入排序是原地排序算法吗?是的,不需要太多额外的空间,O(1)

第二,插入排序是稳定的排序算法吗?是的

第三,插入排序的时间复杂度是多少?最好是时间复杂度为 O(n)。所以最坏情况时间复杂度为 O(n2)。

插入一个数组需要的时间是 O(n),所以,对于插入排序来说,平均时间复杂度O(n^2)

最后来讲的是选择排序(Selection Sort)

其实选择排序的思路挺简单的,就是在一个数组的未排序区域找到最小或者是最大的,放到已排序的区域里,放完就ok

第一,选择排序是原地排序算法吗?是的,不需要太多额外的空间,O(1)

第二,选择排序是稳定的排序算法吗?不是,每次都跟要找找到剩余的最小值,并且和前面的元素交换位置,破坏可稳定性

第三,选择排序的时间复杂度是多少?最好是时间复杂度为 O(n)。所以最坏情况时间复杂度为 O(n2)。

void selectionSort(int a[] , int n)

{
if (n <= 1) return;
for (int i = 0; i < n - 1; i++)

{
int min = a[i];
int j = i + 1;
// 找到未排序区最小元素下标
int flag = i;
for (; j < n; j++)

{
if (a[j] < min) flag = j;
}

// 交换
if (flag != i) {
int tmp = a[i];
a[i] = a[flag];
a[flag] = tmp;
}
}
}

现在来说一下,关于前面的说的为什么插入排序会比冒泡排序用的多?

我们把执行一个赋值语句的时间粗略地计为单位时间(unit_time),然后分别用冒泡排序和插入排序对同一个逆序度是 K 的数组进行排序。用冒泡排序,需要 K 次交换操作,每次需要 3 个赋值语句,所以交换操作总耗时就是 3*K 单位时间。

而插入排序中数据移动操作只需要 K 个单位时间。

猜你喜欢

转载自blog.csdn.net/weixin_38452632/article/details/83090238