如何选择排序算法

        在日常工作中应该如何选择排序算法解决问题?首先,我们要明确几个概念:时间复杂度、额外空间复杂度和算法的稳定性。排序算法都有各自的优劣,在我们的工程中,我们需要结合项目需求,在各有优缺点的算法中做出取舍,或者综合起来考虑。

一、时间复杂度

1、简介

时间复杂度是开发者用来估算算法运行时间的一个指标。

一般我们把要处理的数据规模即作N,则时间复杂度记为 O(f(N)),意为一般情况下,此算法的时间复杂度是 f(N)级别的。

2、时间复杂度分类

一般我们常遇到的几个时间复杂度如下,越往右时间复杂度越高,一般表示算法比较差

O(1) < O(log N) < O(N) < O(N log N) < (O(N^2) < O(n^3) < O(2^n) 

例如:下面函数的时间复杂度为O(1),即常数级别

void myFun(int& a, int& b)
{
    int c = a;
    a = b;
    b = c;
}

下面函数的时间复杂度为O(N)

void myFun(vector<int>& arr)
{
    for(int it : arr)
    {
        cout << it <<endl;
    }
}

3、时间复杂度的作用

        时间复杂度是开发者用于衡量算法运行时间的指标。用于时间复杂度,我们可以根据项目的要求和数据的条件,对不同算法做出取舍。

        一般情况下,我们会忽略时间复杂度的常数参数,而专注研究其函数部分。即使用上述O(1)、O(log N) 、O(N)等等方式来代表算法的时间复杂度。但如果两个算法时间复杂度级别相同,我们可能就要考虑两者的常数部分。但是大部分情况下,因为常数部分对算法运行时间的影响不会那么准确,所以最后还是用实际的运行数据来对两者算法做出取舍。

二、额外空间复杂度

1、简介

额外空间复杂度是使用额外内存空间的大小。

一般我们遇到的额外空间复杂度有:

O(1)  常数级别,只开辟额外几个空间,和数据规模N无关

O(log N) log N级别,一般递归排序有可能遇见

O(N) N级别

例如:下面函数的额外空间复杂度为O(1),算法运行时,只额外开辟了int型变量c的空间

void myFun(int& a, int& b)
{
    int c = a;
    a = b;
    b = c;
}

下面的函数额外空间复杂度为O(N)。算法运行时,开辟了和入参数组 arr 等规模的哈希集合结构hashSet。另外,此算法的时间复杂度为O(N)

void myFun(vector<int>& arr)
{
	unordered_set<int> hashSet;

	for (int it : arr)
	{
		hashSet.insert(it);
	}

	for (int it : hashSet)
	{
		cout << it << " ";
	}
}

2、额外空间复杂度的意义

        额外空间复杂度也是判断算法优劣的重要指标。一般情况下,我们倾向于选择空间复杂度低的算法实现功能,即选择额外空间使用少的算法。但是有些空间复杂度低的算法,时间复杂度更高些,所以具体怎么选择,要考虑项目的需求。

三、排序算法的稳定性

1、定义

同样值的个体之间,如果不因为排序而改变相对次序,则这个排序是有稳定性的,否则就没有。

例如:数组[ 1,5,3,7,3,1,8,9 ],如果排序结果为[1,1,3,3,5,7,8,9],则使用的排序算法是稳定的。否则,排序结果是中11之前,或33之前,则使用的排序算法不具有稳定性。

2、稳定性的意义

        稳定性对于某些数据的排序是没有什么意义的。比如上述整形数组,因为1和1是完全相同的,它们的顺序不会影响结果。但是对于某些自定义的数据类型,比如下面的Student这个结构体来说,如果我们根据年龄age排序,虽然 张三 和 李四都是15岁,但这是2个不同的Student,这种数据情况,就要认真考虑稳定性对结果的影响了。

struct Student
{
	string _name;
	int _age;
	int _address;
    Student(string name, int age, string add) : _name(name), _age(age), _address(add) {}
};

Student("张三", 15, "XXX");
Student("李四", 15, "YYY");

        算法的稳定性在某些需求和数据类型下会成为算法选择的首要考虑。

        例如上述的Student作为数据类型,假如学校要对学生的A、年龄;B、地址远近程度;进行排序。当根据年龄排序完成后,如果使用不稳定的排序算法,再进行根据地址排序时可能会无法保留年龄的排序结果。这种情况下是不符合学校的需求的。

3、如何确认排序算法是否稳定

        一般来说,一个算法是否稳定不能按照某一个公式或性质直接确认出来。但是我们可以找出排序时不稳定的操作,使用排除法确认稳定性。

比如使用选择排序。数组 { 2,2,2,2,1,2,1} ,第一次遍历,会将21交换,则在相同的元素2中,由2,2,2,2变成了2,2,2,2,且最后结果也是如此。则选择排序不具有稳定性。

【注】需要注意的是,一种排序算法如果可以做到稳定,在实现时也可以使其不稳定。如下面两个冒泡排序算法的实现

使用冒泡排序将上述Student数组排序

稳定的冒泡排序:注意看交换的原则是相邻元素逆序才交换,这样会保证相同数值的元素不发生顺序交换

void bubbleSort(vector<Student>& stus)
{
	for (int i = 0; i < stus.size(); ++i)
	{
		for (int j = stus.size() - 1; j > i; --j)
		{
			//逆序则交换
			if (stus[j]._age < stus[j - 1]._age) {
				swap(stus[j], stus[j - 1]);
			}
		}
	}
}

不稳定的冒泡排序:这里面把相同数值的元素也进行了交换,这样就无法保证稳定了

void bubbleSort(vector<Student>& stus)
{
	for (int i = 0; i < stus.size(); ++i)
	{
		for (int j = stus.size() - 1; j > i; --j)
		{
			//逆序和相等都交换
			if (stus[j]._age <= stus[j - 1]._age) {
				swap(stus[j], stus[j - 1]);
			}
		}
	}
}

四、几种重要排序算法的性能总结

        表格中时间复杂度取平均时间复杂度,稳定性取算法最优效果,即算法能够具有稳定性,则此算法就记为具有稳定性。

排序算法性能汇总
排序算法平均时间复杂度空间复杂度稳定性
选择排序O(N^2)O(1)不稳定
冒泡排序O(N^2)O(1)稳定
插入排序O(N^2)O(1)稳定
归并排序O(N*log N)O(N)稳定
快速排序O(N*log N)O(log N)不稳定
堆排序O(N*log N)O(1)不稳定

几个结论:

1、在时间复杂度为O(N*log N)的几个排序中,快速排序是常数项较小的排序,所以相对比较快速;

2、基于比较的排序,目前无法做到时间复杂度优于O(N*log N);

3、基于比较的排序,时间复杂度为O(N*log N)且具有稳定性的排序算法,目前无法做到空间复杂度优于O(N);

常见的坑:

1、归并排序的空间复杂度可以变为O(1),但是非常难,且会丢失稳定性(归并排序-内部缓存法),没有什么使用意义;

2、“原地归并排序”的方法也可以让空间复杂度变为O(1),但是会让时间复杂度变为O(N^2),没有什么使用意义;

3、快速排序可以做到稳定,但是非常难(01 stable sort)论文级别的难度;

4、所有的改进都不重要,因为目前没有找到时间复杂度为O(N*log N),空间复杂度为O(1),且稳定的排序算法;

5、有一道面试题目:把一个数组的奇数放到数组左边,偶数放到数组右边,要求原始数组的相对次序不变,即算法要求具有稳定性。问解决这个问题的方法。

        回答:我们知道快速排序中,经典Paritition的逻辑是01分治的逻辑:其中<=基准值的元素放左边,>基准值的元素放右边。

        而此题中奇数和偶数分治也是一个01分治的问题,所以我们可以回答:因为我们想到了快速排序的思想中可以实现这种01分治的问题,但是经典快速排序没有稳定性。所以我不会解答这个问题。

        其实快速排序可以做到稳定,见上面第3点,有论文阐述了这个方法,但是非常难。

五、排序算法在工程中的应用

在工程上,有些程序员可能会根据项目需求和数据状况对自己的排序算法做一些优化。

1、例如下面代码中的Sort类提供的排序算法,基于时间复杂度的考虑,我们可以做出优化:

1)使用快速排序实现基础调度;2)在数据量小的时候使用插入排序快速得到结果

由于插入排序的时间复杂度 f(N)的常数项系数较小,在数据量小的时候排序比较快速。所以经常会这么使用。

class Sort
{
public:
	void sort(int A[], int len)
	{
		QuickSort(A, 0, len-1);
	}

private:
	//插入排序
	void insertionSort(int nums[], int left, int right)
	{
		for (int i = left; i <= right; ++i)
		{
			int temp = nums[i]; //将temp值插入合适位置
			int j = i;
			//向有序序列查找temp的位置
			while (j > left && temp < nums[j - 1])
			{
				nums[j] = nums[j - 1];
				--j;
			}
			nums[j] = temp;
		}
	}

	//分治
	int Partition(int A[], int left, int right)
	{
		//以最左元素为基准值
		int pivot = A[left];
		while (left < right)
		{
			//交替遍历右项,如果A[high]>=pivot,则继续遍历high指针
			while (left < right && A[right] >= pivot)
			{
				--right;
			}
			//如果A[right]<pivot,把A[right]赋给A[left],以实现把小于基准值的值放到左侧的目的
			A[left] = A[right];
			//交替遍历left指针
			while (left < right && A[left] <= pivot)
			{
				++left;
			}
			A[right] = A[left];
		}
		A[left] = pivot;
		//最后返回最新的基准值所在位置
		return left;
	}

	//快速排序
	void QuickSort(int A[], int left, int right)
	{
		if (left < right)
		{
			//如果数据量比较小,则不再分治,使用插入排序快速得到结果
			if (right - left < 60)
			{
				insertionSort(A, left, right);
				return;
			}
			//获取基准值位置
			int pivot = Partition(A, left, right);
			//根据基准值的位置,分成左右2个子数组,递归下去
			QuickSort(A, left, pivot);
			QuickSort(A, pivot + 1, right);
		}
	}
};

2、在工程中,有时候可能会基于稳定性的考虑:将一些基础类型(不需要稳定性的数据类型)使用快速排序法;将一些自定义类型(可能会需要稳定性的数据类型)使用归并排序。

总结:

        一个成熟工程中用到的排序算法,常常根据工程需求和数据状况,使用不同排序算法的优势,综合起来实现排序。包括成熟的基础库中的排序方法,比如C++中的sort方法,都是考虑了各种排序算法的优势,综合做出的算法。

【参考&致谢】

一周刷爆LeetCode,算法大神左神(左程云)耗时100天打造算法与数据结构基础到高级全家桶教程,直击BTAJ等一线大厂必问算法面试题真题详解_哔哩哔哩_bilibili

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值