数据结构与算法 -- 11 排序(上) | 冒泡、插入、选择排序以及分析方法

引子:

  • 为什么要学习排序算法?
      排序是程序员们最早接触的一类算法,也是大部分面试必考技能:手写一个简单的常见排序算法,给出时间复杂度(最好,最坏,平均);简单点的让你口述下算法原理等等。之所以这么着重考察,我的经验是一来实际软件很多场景中排序是经常用到的算法;二来排序可以考察基本的算法能力–执行效率、内存消耗、稳定性,分治和归并思想等等,常见排序算法虽简单,但是要深入理解他们,这些分析方法和思想都是需要的。
  • 时间复杂度一样,为什么实际开发中,插入排序比冒泡排序更受欢迎?

算法的原理、代码实现固然重要,但更重要的是如何评价和分析算法,这种更通用、可迁移的能力才是我们更看中更受用的。首先介绍排序算法的分析方法,然后分别对冒泡、插入、排序这几大算法进行分析。

一. 如何分析排序算法

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;
}

详见:bubble_sort.c

  • 几点注意

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;
}

详见:insertion_sort.c

  • 算法指标

最好时间复杂度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;
}
发布了60 篇原创文章 · 获赞 27 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/xiaosaerjt/article/details/98024179