冒泡排序很形象,即让较大元素想泡泡一样不断往上冒,代码如下。
void bubblesort1(int *array,int n)
{
for(int i = 0;i<n;i++)
for(int j = 0;j<n-i-1;j++)
{
if(array[j]>array[j+1])
swap(array[j],array[j+1]);
}
}
优化一:如图,在进行两轮冒泡后,排序已经完成,所以不需要再进行下去,即当一个循环内无元素的交换,代表序列已经有序了,此时可以跳出,减少循环次数。
void bubblesort2(int *array, int n)//简单优化
{
for(int i = 0;i<n;i++)
{
bool flag = true;
for(int j = 0;j<n-i-1;j++)
{
if(array[j]>array[j+1])
swap(array[j],array[j+1]);
flag = false;
}
if(flag)
break;
}
}
优化二:同上,每次直接记录最后交换元素的位置,其往后的元素已经有序,故下一次循环只需要比较到此位置即可。
void bubblesort3(int *array,int n)
{
int lastchange = 0;
int sortborder = n-1;
for(int i = 0;i<n;i++)
{
bool flag = true;
for(int j = 0;j<sortborder;j++)
{
if(array[j]>array[j+1])
{
swap(array[j],array[j+1]);
flag = false;
lastchange = j;
}
}
sortborder = lastchange;
if(flag)
break;
}
}
顾名思义,选择排序即每次选择范围内最小的元素,把其放到最前的位置。选择排序和冒泡排序的不同在于,前者没有进行频繁的元素交换,而是在范围内遍历完后,再进行替换。
void choisesort(int *list,int n)
{
for(int i = 0;i<n;i++)
{
bool flag = true;
int m = i;
for(int j = i+1;j<n;j++)
{
if(list[j]<=list[m])
{
m = j;
flag = false;
}
}
if(flag)
break;
swap(list[i],list[m]);
}
}
选择排序被称为不稳定的排序,因为对于相同的元素,通过选择排序过后,可能会改变相同元素间的想对位置。如下图,这是选择排序的一个缺陷。
插入排序,即假设前面的序列有序,然后用后面的元素不断与前面的有序序列比较,直到其能“插入”到一个合适的地方,即元素需要不断往后退,腾出空间给元素插入。
void insertsort(int *list,int n)
{
int tmp,i,j;
for(i = 1;i<n;i++)
{
tmp = list[i];
for(j = i-1;j>=0&&list[j]>tmp;j--)
list[j+1] = list[j];
list[j+1] = tmp;
}
}
鸡尾酒排序是冒泡排序的一种变式,适用于大部分有序序列的排序,如下图
如果使用冒泡排序,那么整个过程是这样的
这时我们就会发现,原本1和9中间的元素是有序的,只需要1和9变化即可完成,可是使用冒泡排序却要进行如此多次变化。
这时候鸡尾酒排序就有发挥的作用了,很容易想到,当9排到最后时,只需要对1进行往前的冒泡排序,就可以在一个循环内完成排序了。
即像一个钟摆那样,来回方向进行冒泡排序。
优化方式同前面的冒泡。
void cocktailsort(int *array,int n){
for(int i = 0;i<n/2;i++){
for(int j = i;j<n-1-i;j++){
if(array[j]>array[j+1])
swap(array[j],array[j+1]);
}
for(int j = n-2-i;j>i;j--){
if(array[j]<array[j-1])
swap(array[j],array[j-1]);
}
}
}
第一梯队的排序方法的平均时间复杂度均为O(n^2),空间复杂度O(1)。
上述各种算法没有说哪种最优,只是不同的算法使用与不同的情况。
(1)如插入排序和冒泡排序,两种排序都通过对元素频繁的交换来进行排序,交换的次数,即排序方法的性能取决于序列的有序程度。
插入排序的元素交换是连续的,而冒泡是两两相关,彼此独立的。显然,插入排序可以省去很多无用的比较,基于这个因素,插入排序的性能略高于冒泡排序。
(2)我们再来看选择排序,选择排序与上述两种方法有些不同,选择排序的比较次数是确定的,每次都选取范围内最小元素排到前面,至于序列的有序程度如何,对选择排序的比较次数是无影响的。
所以,对于大部分有序的序列,插入排序优;对于大部分无序的序列,选择排序优。
冒泡和插入是稳定算法,因为值相同的元素排序前后相对位置不会改变。而选择排序可能会改变相同元素的相对位置,所以选择排序不是稳定的排序。
希尔排序其实就是一种分组的插入排序。回想一下如何去优化插入排序的性能,或者什么样的序列用插入排序会更为高效。
1)更短的序列
2)更为有序的序列
如果满足以上的特点,那么将会大大减低插入排序的工作量。而希尔排序就是利用这两点,利用增量(即分组的跨度)来讲序列分成多个小组,组内进行插入排序。
至于增量的选取则有很多,例如Hibbard增量和Sedgewick增量,这里不讨论。
void hellsort(int *list,int n)//n为增量
{
int d = n;
while(d>1)
{
d = d/2;
for(int i = 0;i<d;i++){
for(int j = i+d;j<n;j = j+d)
{
int tmp = list[j];
int y;
for(y = j-1;y>=0&&list[y]>tmp;y--)
list[y+1] = list[y];
list[y+1] = tmp;
}
}
}
}
快速排序运用一种分治的思想,即选择一个数,排序完成后,该数左边的数都小于该数,该数右边的数都大于该数。然后再分为两个子数组继续递归下去。流程大致如下。
首先定义两个指针left,right,以序列第一个数作为划分序列的边界,从右边向左找到第一个比其小的数,将该值覆盖到L的位置。
然后从L开始,从左往右遍历,找到第一个比9大的数。覆盖掉R所指元素。
重复上述步骤,最后循环跳出,得以下结果。
最后将tmp覆盖掉该位置,可看到9左边的元素都小于9,9右边的元素都大于9。
此时序列还没有完成排序,需要以9为界,把序列分成左右子部分,重复上述操作。
int fast_sort(int *list,int left,int right)
{
int tmp = list[left];
while(left<right)
{
while(list[right]>=tmp&&left<right)
right--;
list[left] = list[right];
while(list[left]<=tmp&&left<right)
left++;
list[right] = list[left];
}
list[left] = tmp;
return left;
}
void fastsort(int *list,int left,int right)
{
if(list==NULL||left>right)
return ;
int tmp = fast_sort(list,left,right);
fastsort(list,left,tmp-1);
fastsort(list,tmp+1,right);
}
归并排序,即先拆后并,不断两两分组,把序列拆分成小序列,内部排序后再依次合并继续排序。
流程大致如下,取中间为界不断将序列分割。
分割后再每小组进行排序,一层一层地排下去。
整个流程其实很明了,层数logn层,每层都共有n个元素需要排序,所以时间复杂度为O(nlogn),而空间复杂度为O(n),因为每次拆分排序后,每一层的临时数组都是n,一层过后临时数组都会被释放,所以不需要叠加计算。
void func(int *list,int *tmp,int start,int end)
{
if(start>=end)
return ;
int mid = (start+end)/2;
int start2 = mid+1;
int start1 = start;
int end1 = mid;
int end2 = end;
func(list,tmp,start1,end1);
func(list,tmp,start2,end2);
int k = start;
while(start1<=end1 && start2<=end2)
{
tmp[k++] = list[start1]>list[start2]?list[start2++]:list[start1++];
}
while(start1<=end1)
tmp[k++] = list[start1++];
while(start2<=end2)
tmp[k++] = list[start2++];
for(int i = start;i<=end;i++)
list[i] = tmp[i];
}
void merge(int *list,int n)
{
int tmp[n];
func(list,tmp,0,n-1);
}
堆排序利用了二叉堆这一个数据结构,二叉堆分为两种,有大顶堆和小顶堆。其利用了完全二叉树的特性,以数组作为底层结构,父节点下标为i,则其左节点为2i+1,右节点为2i-1。子节点的父节点为(i-1)/ 2。
在STL中,堆heap有两种基本操作,即上溯和下溯。在堆尾部插入元素,通过上溯使堆更新。下溯即把堆顶往下比较,sort_heap和make_heap都利用了下溯。
堆排序流程:
(1)先把序列构造成大顶堆
(2)然后将非叶子节点进行下溯操作
void _pop_heap(int *array,int parent,int len){
int tmp = array[parent];
int child = parent*2+1;
while(child<len){
if(child+1<len&&array[child]<array[child+1])
child++;
if(array[child]<=tmp)
break;
array[parent] = array[child];
parent = child;
child = parent*2+1;
}
array[parent] = tmp;
}
void _make_heap(int *array,int len){
for(int i = (len-2)/2;i>=0;i--){
_pop_heap(array,i,len);
}
}
void _sort_heap(int *array,int len){
_make_heap(array,len);
for(int i = 0;i<len;i++){
swap(array[0],array[len-1-i]);
_pop_heap(array,0,len-i-1);
}
}
构造堆需要进行n/2次下溯,排序时需要n次下溯,每次下溯logn,故综合起来时间复杂度为O(nlogn),无需额外空间,空间复杂度为O(1)。
第二梯队算法的平均时间复杂度均为O(nlogn),其中希尔排序最快可达O(n^1.3)。
快排最坏情况下复杂度会退化至O(n^2),例如序列 [9,1,2,3,4,5,6,7],需要n次分组。
而堆排序相对于归并和快排性能又略低一些,因为二叉堆中父子节点并非是在连续空间上,对于底层的缓存机制来说,不命中率会高一些。
所以堆排序和快速排序都不是稳定的,而归并排序是稳定的,因为无论序列如何,也不会影响其排序的步骤。
不过归并排序需要额外O(n)的空间,而快排和堆排序都为原地算法。
计数排序很好理解,即额外开拓标记数组记录每个数出现的次数,然后再按次序依次排列出来。比如一个序列只有4种数,[1,2,3,4,2,3,1,2,3,2],通过遍历数组可知1出现了两次,2出现了四次,3出现了三次,4出现了一次。
以这种方式(数组大小为序列最大值)开辟数组很容易发现一个问题,如以下序列[90,91,92,97,99,98],若以最大值开辟数组,那么中间大部分的空间都会被浪费。
因此采用一种优化的方法,取最大最小值之差加1来开辟数组,然后元素减去最小值所得的值作为下标。
就目前看来,计数排序算不上是一种排序,它只是记录数据出现次数,然后按序重构序列,对于相同数的相对位置等信息,单纯的计数排序无法保存。下面将介绍如何改良。
如要对下图跳远成绩进行排序。
按上述方法先申请标记数组(15-7+1)
在这里要进行特殊处理,即数组元素的值应为前面元素及自身的累加,如下图。
这样做的目的,其实记录出元素在最终结果数组中出现的位置。此时我们再从后往前遍历序列,将上述数组对应下标元素的值减1,即为其在最终结果数组中的位置。执行完后,要在数组相应位置减1。
void sort(int* array,int n,int *res){
int max = array[0];
int min = array[0];
for(int i = 1;i<n;i++){
//取最大最小值
max = max(array[i],max);
min = min(array[i],min);
}
int d = max-min+1;
int arr[d] = {
0};
for(int i = 0;i<n;i++)//先正常标记
arr[array[i]-min]++;
for(int i = 1;i<n;i++)//特殊处理数组
arr[i] += arr[i-1];
for(int i = n-1;i>=0;i--){
//插入元素
res[arr[array[i]-min]-1] = array[i];
arr[array[i]-min]--;
}
}
算法时间复杂度很好分析,为O(m+n)其中m为最大值和最小值之差。空间复杂度O(m)
计数排序缺点明显,显示中接触不多,对于排序序列非整数,最大最小值差距较大时,计数排序就没什么使用的必要了。
基数排序是基于计数排序的一种排序,即如果需要对一系列字符串进行排序,只需要由低位到高位开始依次对每一位元素进行计数排序。
桶排序大致是按序列元素情况,申请一定数量的桶,所谓桶,即大小在该区间内的数都会被放到该桶里面,在每个桶里面再各自做排序,最后有序将桶内元素倒出来即可。
以上三种算法均属于线性时间复杂度的排序算法,均稳定。其中
计数排序的时间复杂度是O(n+m)。
桶排序的时间复杂度是O(n),这是在分桶数量是n的前提下。
基数排序的时间复杂度是O(k(n+m)),其中k是元素的最大位数。
参考文章