各种排序算法总结

比较排序

选择排序

算法思想

每次从数组中选择一个最值元素,取出,并不断重复。最后得到的就是一个有序的序列。

算法实现

void selection_sort(int a[],int n) 
{//假设数组从0开始
	int idx;
	for(int i=0;i<n;++i)
	{
		idx=i;
		for(int j=i;j<n;++j)
		{
			if(a[j]<a[idx])
			{
				idx=j;
			}
		}
		swap(a[i],a[idx]);
	}
}

复杂度分析

Θ ( n 2 ) \Theta(n^2) Θ(n2)
具有排序稳定性
需要比较次数 Θ ( n 2 ) \Theta(n^2) Θ(n2)

冒泡排序

算法思想

和选择排序的思想比较接近,每次通过不断比较相邻的元素把较大的元素向后移动,最后使得最大的元素达到最右边(冒泡),并不断重复上面的过程。

算法实现

void bubble_sort(int a[],int n)
{//假设下标从1开始 
	for(int i=n;i>0;--i)
	{
		for(int j=1;j<i;++j)
		{
			if(a[j]>a[j+1])
			{
				swap(a[j],a[j+1]);
			}
		}
	}
}

复杂度分析

Θ ( n 2 ) \Theta(n^2) Θ(n2)

插入排序

算法思想

将数组分为有序区和无序区,每次把无序区中和有序区交界的元素加入有序区,直到所有的元素都处于有序区。

加入的方法是:从有序区中从后往前寻找合适的位置,然后插入。

这里合适的位置是指,如果从小到大进行排序,则应该插入的位置是前面比他都小,后面比他都大的。当然我们可以对相等的情况进行判断,是选择继续前移还是停止移动。因此不难发现,插入排序具有稳定性

在寻找合适的插入位置的时候我们可以同时完成数组的移动操作。

算法实现

按照上面的思路我们可以直接进行实现

void insert_sort(int a[],int n)//从0开始
{
	int tmp;
	for(int i=1;i<n;++i)//不断扩大有序区 
	{
		tmp = a[i];	//暂存需要移动的元素 
		for(int j=i-1;j>=0 && a[j]>tmp;--j)	//满足前移条件 
		{//注意j>=0必须写在前面,否则可能产生越界
			a[j+1]=a[j];
		}
		a[j+1] = tmp;	//找到了合适的位置 
	}
}

上面的排序我们需要不断判断内层循环的位置以防止如果需要插入元素在数组最顶部的话导致数组越界。但是如果数组是从1开始的话其实有更好的做法:哨兵法

大概的思路就是用第0个元素当作上面代码中的tmp,然后还要求在判断中不能有=。这样如果需要移动到数组首部的话就会遇到自己,就会停下来。

void insert_sort(int a[],int n)//从1开始
{
	for(int i=2;i<=n;++i)//不断扩大有序区 
	{
		a[0] = a[i];	//暂存需要移动的元素 
		for(int j=i-1; a[j]>a[0]; --j)	//满足前移条件 
		{
			a[j+1]=a[j];
		}
		a[j+1] = a[0];	//找到了合适的位置 
	}
}

复杂度分析

时间复杂度是 Θ ( n 2 ) \Theta(n^2) Θ(n2)

最大比较次数是 O ( n 2 ) O(n^2) O(n2)

平均比较次数是 Θ ( n 2 4 ) \Theta(\frac{n^2}{4}) Θ(4n2)

最好比较次数 Θ ( n ) \Theta(n) Θ(n)

对于输入规模n比较小的时候,或者输入已经基本有序的时候,算法效率较高,如果比较大,则较低。

如上面所说,具有排序稳定性。

归并排序

算法思想

使用分治法,将数组分成多个部分进行排序,然后再将排序好的数组进行合并。

比较常见的是二路归并排序

合并的思路是每次寻找最小(大)的元素。而对于已经排序好的两部分数组,最值元素只能是两个数组中最值中的一个。因此我们不断从两个数组的头部取出元素,最后拼接成一个完整的数组。

对于顺序表,我们不能够进行原地操作,因此需要和原数组同等大小的辅助空间。

算法实现

void merge_sort(int a[], int b[], int l, int r)
{//b为辅助数组
	if(r-l <= 1) return; //如果数组长度<=1,直接返回
	int mid = (l+r)>>1;
	merge_sort(a,b,l,mid);
	merge_sort(a,b,mid,r);
	
	int i=l; int j=mid;
	int idx=l;
	while(i<mid || j<r)
	{
		if(j>=r || a[i]<=a[j])
			b[idx++]=a[i++];
		else
			b[idx++]=a[j++];
	}
	for(int k=l;k<r;++k)
	{
		a[k]=b[k];
	}
}

复杂度分析

递归式为 T ( n ) = 2 T ( n / 2 ) + Θ ( n ) = Θ ( n l o g n ) T(n)=2T(n/2)+\Theta(n)=\Theta(nlogn) T(n)=2T(n/2)+Θ(n)=Θ(nlogn)

空间复杂度为 Θ ( n ) \Theta(n) Θ(n)

当n>30的时候,用归并排序比插入排序更快

具有稳定性

快速排序

算法思想

同样利用分治法,首先选择一个枢纽元素,然后再根据比枢纽元素大还是比枢纽元素小将数组分为左右两部分。

虽然思想比较简单,但是枢纽元素的选择和划分方法的选取对算法的性能影响比较大。详细可以了解一下我之前写的博客:
快速排序详解+各种实现方式
随机化快速排序+快速选择 复杂度证明+运行测试
经过上面的讨论,最后的结论是:对于快速排序,使用三者取中法选择枢纽,使用两侧直接划分的方法得到的算法鲁棒性最好,效率最高。

算法实现

void getPovit(int a[],int l,int r)
{
	int mid=(l+r)>>1;
	if(a[l]>a[mid]) swap(a[l],a[mid]);
	if(a[r-1]>a[mid]) swap(a[r-1],a[mid]);
	if(a[l]>a[r-1]) swap(a[l],a[r-1]);
}

void quick_sort(int a[],int l,int r)
{
	if(r-l<2) return;
	getPovit(a, l, r);
	int povit=a[l];
	int i=l-1,j=r;
	while(i<j)
	{
		do ++i; while(a[i]<povit);
		do --j; while(a[j]>povit);
		if(i<j) swap(a[i],a[j]);
	}
	quick_sort(a,l,j+1);
	quick_sort(a,j+1,r);
}

复杂度分析

Θ ( n l o g n ) \Theta(nlogn) Θ(nlogn)

不具有排序稳定性

堆排序

算法思想

使用堆这种能够很方便维护最值的数据结构。对于建成的堆,每次把堆顶元素(最值)取出,再将剩下的元素维护成一个堆,直到将堆中的元素取完。通过这种方式进行排序。

算法实现

用数组实现的话需要注意数组应该用1开始。

void adjust_heap(int a[],int n,int idx)
{
	int x = a[idx];
	for(int k=idx<<1; k<=n; k<<=1)
	{
		if(k+1<=n && a[k+1]>a[k])
		{//如果有两个儿子,选择两个儿子里面较大的那个 
			++k;
		}
		if(a[k] > x)
		{//如果儿子比父亲大,则将儿子上移 
			a[idx] = a[k];
			idx = k;//把原父亲节点下移 
		} else
		{//比两个儿子节点都大 
			break;
		}
	}
	a[idx] = x;	//注意这个语句只能放在循环语句外面 
}

void make_heap(int a[],int n)
{
	for(int i=n/2; i>0; --i)
	{
		adjust_heap(a,n,i); 
	}
}

void heap_sort(int a[],int n)//数组下标必须从1开始,如果不是需要进行处理 
{
	make_heap(a,n);
	for(int i=n; i>1; --i)
	{
		swap(a[i],a[1]);	//将最大值移动到尾部
		adjust_heap(a,i-1,1);	//将根节点出现错误的堆进行调整,需要注意此时的堆的大小已经发生变化 
	}
}

复杂度分析

建堆的复杂度为 O ( 4 n ) O(4n) O(4n),每次调整的复杂度为 O ( l o g n ) O(logn) O(logn),即堆的深度。需要排序n次,总共的复杂度为 O ( n l o g n ) O(nlogn) O(nlogn)

不具有排序稳定性

总结

比较排序的复杂度不会优于 Θ ( n l o g n ) \Theta(nlogn) Θ(nlogn)

证明:所有比较排序都可以转化成决策树的形式。每个决策树的内部节点是一次比较过程,叶子节点是排序结果。如果一个排序算法是有效的,那么它应该对输入规模为n的各个排列都能够进行排序。这就要求叶子节点的个数最少应该为 n ! n! n!个。因为决策树是一个二叉树,假设树的深度为 h h h,则 n ! < 2 h − 1 n!<2^{h-1} n!<2h1,即 h > l o g n ! + 1 h>logn!+1 h>logn!+1,将 n ! n! n!进行泰勒展开就得到 h > Θ ( n l o g ) h>\Theta(nlog) h>Θ(nlog)。而树的深度就是算法时间复杂度,证毕。

其他

是指一些突破比较排序模型的排序算法

桶排序

算法思想

用一个桶(数组)来记录每个数字出现的次数:每出现一次数字icnt[i]+=1
然后维护桶的一个前缀数组cnt[i]+=cnt[i-1],这个时候cnt[i]代表数字i出现的最大位置
将原数组A从后往前进行遍历,对于遍历到的元素x=A[i],将其放在数组B[cnt[x]]处,并且将cnt[x]-=1

最后得到的B数组就是排序成功的,并且具有排序稳定性!

算法实现

void counting_sort(int a[],int b[],int c[],int n,int k)
{//数组下标从0开始 
	for(int i=0;i<=k;++i) c[i]=0;
	for(int i=0;i<n;++i) ++c[a[i]];
	for(int i=1;i<=k;++i) c[i]+=c[i-1];
	for(int i=n-1;i>=0;--i)
	{
		b[c[a[i]]-1]=a[i];	//为了保持数组从0开始,否则应该不用-1
		--c[a[i]];
	}
}

复杂度分析

假设数字范围为0-k,则时间复杂度为 Θ ( n + k ) \Theta(n+k) Θ(n+k)

k比较大的时候不适用,而且需要 n + k n+k n+k的辅助空间

具有排序稳定性

基数排序

算法思想

不再按照比较排序的模型,而是按照数字各个位进行排序。
可能第一想法是从高位向低位进行排序,因为高位起决定地位,这样就可以不断缩小数据规模。但实际上这种做法没有从后往前进行排序简单。

从后往前进行排序不需要对数字进行分类,进行排序的时候要求使用的排序算法具有排序稳定性,而且复杂度要低,上面的桶排序就很好。

本质上这种排序能够成功的原因还是因为高为起决定地位。(很多时候直觉的做法不是最优的)

算法实现

这里简单按照每一位进行排序。其实效率更高的做法应该是把数字看作二进制串并进行分块,每一块当作一位。

void radix_sort(int a[],int n,int digit)
{//下标从1开始,最多digit位 
	int c[10];	//桶 
	int b[1005];//辅助数组 
	int radix[5]={1,10,100,1000,10000};
	for(int k=0;k<digit;++k)
	{//每一位是一轮,每一轮进行桶排序 
		for(int i=0;i<10;++i) c[i]=0;
		for(int i=1;i<=n;++i) ++c[a[i]%radix[k+1]/radix[k]];
		for(int i=1;i<10;++i) c[i]+=c[i-1];
		for(int i=n;i>0;--i)
		{
			b[c[a[i]%radix[k+1]/radix[k]]]=a[i];
			--c[a[i]%radix[k+1]/radix[k]];
		}
		for(int i=1;i<=n;++i) a[i]=b[i];
	}
}

复杂度分析

在上面的算法中,假设每个元素最多x位,则时间复杂度 Θ ( x n ) \Theta(xn) Θ(xn)

实际上更优秀的做法应该是对于每个元素最多是b位(二进制),我们按照每r位进行分块,则时间复杂度为 Θ ( b r ⋅ ( n + 2 r ) ) \Theta(\frac{b}{r}\cdot (n+2^r)) Θ(rb(n+2r)),进行求导得到驻点, r = l o g n r=logn r=logn的时候比较好。此时的时间复杂度为 Θ ( b l o g n ⋅ n ) \Theta(\frac{b}{logn}\cdot n) Θ(lognbn),令 2 b = n d 2^b=n^d 2b=nd,则时间复杂度为 Θ ( d n ) \Theta(dn) Θ(dn)

虽然基数排序理论上复杂度比较优秀,而且也能处理比较大范围的数字。但是实际使用的场景不是很多,因为对需要比较的数字的要求比较高,必须是正整数,而且常数也比较大,因此我们更多的选择快速排序。

你可能感兴趣的:(算法)