常见排序方法
众所周知,排序在项目实战,实际问题,算法题等等方面有着非常多的用处。而排序的方法有很多,他们的实现方法和思想各异,功能效率也有所不同。本篇文章博主将介绍8种常见的排序算法的思想,实现方式。(用C语言实现)
目录
- 常见排序方法
- 目录
- 1、插入排序
- 1.1 直接插入排序
- 1.1.1直接插入排序代码:
- 1.2希尔排序
- 1.2.1希尔排序代码:
- 2、选择排序
- 2.1直接选择排序
- 2.1.1直接选择排序代码:
- 2.2 堆排序
- 2.2.1建堆
- 编辑2.2.1.1向下调整算法代码:
- 2.2.1.2建堆代码:
- 2.2.2排序
- 2.2.2.1堆排序完整代码
- 3、交换排序
- 3.1冒泡排序
- 3.1.1冒泡排序代码
- 3.2 快速排序
- 3.2.1挖坑法处理序列
- 3.2.1.1挖坑法代码
- 3.2.2用递归的方式处理剩下部分
- 3.2.2.1递归
- 3.2.2.2递归展开图
- 4、归并排序
- 4.1操作逻辑:
- 4.1.1合并区间
- 4.1.1.1归并排序合并区间代码
- 4.1.2递归归并排序(后序遍历)
- 4.1.2.1递归代码
- 4.1.3完整代码
- 5、计数排序
- 5.1操作逻辑
- 5.1.1计数排序代码
- 总结
1、插入排序
1.1 直接插入排序
直接插入排序是一种简单的插入排序法,其基本思想是:把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列
假如我们有一个序列“5,2,4,6,1,3”我们需要对其从小到大排序,让我们用图来展示一下整个过程:
移动3也是类似的过程,博主在这里就不演示了。经过图的演示,我们容易概括出这个排序宏观上的操作即:比temp大的数就会向后挪动,比temp小的就不挪动此时temp会覆盖掉当前情况下重复的那个数。宏观上就是一个插入的过程与大家完扑克牌时理牌的过程非常相似。
时间复杂度:最坏情况:逆序O(N^2),最好情况:顺序O(N),平均情况O(N^2)
1.1.1直接插入排序代码:
void InsertSort(int *a,int n)
{
for(int i =0 ;i<n-1;i++)
{
int end=i;
int temp = a[end+1];
while(end>=0)
{
if(a[end] > temp)
{
a[end+1] = a[end];
end-=1;
}
else
{
break;
}
}
a[end+1] = temp;
}
}
1.2希尔排序
在了解完直接插入排序后,不难看出直接插入排序,要多次挪动数据时间复杂度有些高,那是否有直接插入排序的优化版本呢?好,现在我们来看看希尔排序!! 希尔排序是是直接插入排序的一个优化方法,也叫缩小增量排序:1、先进行预排序,让序列接近有序。预排序,我们设置gap对有一定间距的不同组数先进行分组排序,然后逐渐缩小gap使得序列接近有序 2、然后进行直接插入排序,使得时间复杂度接近O(N)。因为直接插入排序最好的情况是正序O(N)而我们将序列预排序到接近正序的时候,那我们的直接插入的效率就会高很多。
假设我们有序列"9,1,2,5,7,4,8,6,3,5"
gap>1就是预排序;由图不难看出,gap越大跨度越大,大的数越容易跑到后面小的数越容易跑到前面;gap越小跨度小,而序列就越接近有序;gap==1就是直接插入排序,此时效率已经得到了不小的提升
这里还有一个问题:gap该如何选择? 常见的gap缩小规律:gap = gap/3+1或者gap/=2;只要我的gap在缩小的最后一次为1即可,而gap也不能超过序列中数字的个数。
1.2.1希尔排序代码:
void ShellSort(int *a,int n)
{
int gap = n;//将gap与n关联
while(gap>1)
{
gap = gap/3+1;
//gap/=2;
for(int i =0 ;i<n-gap;i++)///是i++而不是i+=gap这样设计可以使不同组数在同一轮同时排序
{
int end=i;
int temp = a[end+gap];
while(end>=0)
{
if(a[end] > temp)
{
a[end+gap] = a[end];
end-=gap;
}
else
{
break;
}
}
a[end+gap] = temp;
}
}
}
该代码设计的优点:在for循环中,i的增加条件是i++而不是i+=gap,这样可以使不同组的数,在同一轮中同时排序,而不是先排完A子序列再排B子序列再排C子序列。整体来看,同一轮中gap相同假如分为了A,B,C子序列整个过程就为A,B,C交错排序。当i增加到A子序列的下一位数字时,此时A,B,C子序列中的数就分别进行了一次比较甚至位置交换,这样做既使i增加到了i+=gap的位置又让其他子序列得到操作。由此以来,直到i跑完的时候,所有的子序列就都会完成排序。
希尔排序的时间复杂度: 平均:O(nlogn)~O(n^2);最好O(N^1.3);最坏O(N^2),同时它是个不稳定排序,它效率会受到gap的影响
2、选择排序
选择排序的基本思想:每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全 部待排序的数据元素排完 。
2.1直接选择排序
假如我们有序列“7,2,5,4,8,1,6”,我们用图画来看看直接选择排序的过程
整个过程就是:先把begin和end放在序列的首尾,移动begin,找出[begin,end]这个范围中的最大数和最小数与分别与begin和end位置上的数进行交换
2.1.1直接选择排序代码:
void SelectSort(int*a ,int n)
{
int begin =0;
int end = n-1;
while(begin<end)
{
int mini = begin,maxi = begin;
for(int i =begin;i<=end;i++)
{
if(a[i]<a[mini])
{
mini = i;
}
if(a[i]>a[maxi])
{
maxi = i;
}
}
Swap(&a[begin],&a[mini]);
Swap(&a[maxi],&a[end]);
begin++;
}
}
时间复杂度:O(N^2)
2.2 堆排序
在介绍堆排序前,我们得先知道什么是堆?
堆的结构:堆的逻辑结构是一颗完全二叉树,堆的物理结构是一个数组。通过父子节点的下标关系进行检索:
//左子树下标 leftchild = parent*2+1
//右子树下标 rightchild = parent*2+2
//母节点下标 parent = (child-1)/2
堆的有序性:堆分为大小堆
//大堆:树中所有的父亲都大于等于孩子
//根是最大的
//小堆:树中所有的父亲都小于等于孩子
//根是最小的
那一个数组将如何变成二叉树的样子方便观察呢???
按完全二叉树的结构从前向后放置得到
图例1.
2.2.1建堆
知道了堆的概念以及一些属性,接下来看看如何建堆吧!
建堆的过程需要用到向下调整算法:
利用向下调整算法进行建堆
前提: 若想将其调整为小堆,那么根结点的左右子树必须都为小堆。
若想将其调整为大堆,那么根结点的左右子树必须都为大堆。方法:1、比较两子节点的值,2、取出较小(较大)的值跟父亲节点比较,如果比父亲节点小(大)那就交换,依次向下调整直到叶子节点,调整完毕之后就形成了一个小(大)堆。
升降序:排升序建大堆,排降序建小堆
2.2.1.1向下调整算法代码:
void AjustDown(int*a,int n,int root)//向下调整算法
{
int parent = root;
int child = parent*2+1;//从左子节点开始比较i6
while(child<n)
{
//选出左右子树中小(大)的那一个
if(child+1<n &&a[child+1]>a[child])
{
child=child+1;
}
if(a[child]>a[parent])
{
Swap(&a[child],&a[parent]);
parent = child;//向下走
child = parent*2+1;
}
else
{
break;
}
}
}
不难发现,不可能所有情况下根节点的左右子树都是小(大)堆,那我们该如何用向下调整算法面对所有的情况?
不妨我们反过来想,不论是什么树,我们先让左右子树是小堆或者大堆不就行了。我们可以从树的最底层叶子节点的父亲节点出发,向上调整。(假设现在建小堆)
直到走到堆顶,此时左右子树都是小堆了,然后在从堆顶向下调整,然后一个小堆就建好了
2.2.1.2建堆代码:
//1.建堆
for(int i =(n-1-1)/2;i>=0;--i)//n-1为最后一个元素,(n-1-1)/2其实就是该元素对应的父亲节点
{
AjustDown(a,n,i);
}
2.2.2排序
建好堆后,我们就可以开始排序了!
基本思想:我们设立一个end作为数组最后一个元素的下标。我们知道到一个小(大)堆的堆顶就是这个堆最小(大)的元素。这样我们可以将堆顶的元素与end元素交换,然后将交换后的放到end位置的元素排除在堆之外,逐步缩小end的范围,对剩下堆内的元素进行调整,直到排序完成。
2.2.2.1堆排序完整代码
void AjustDown(int*a,int n,int root)//向下调整算法
{
int parent = root;
int child = parent*2+1;
while(child<n)
{
//选出左右子树中小(大)的那一个
if(child+1<n &&a[child+1]>a[child])
{
child=child+1;
}
if(a[child]>a[parent])
{
Swap(&a[child],&a[parent]);
parent = child;//向下走
child = parent*2+1;
}
else
{
break;
}
}
}
void HeapSort(int*a, int n)
{
for(int i =(n-1-1)/2;i>=0;--i)
{
AjustDown(a,n,i);
}
int end = n-1;
while(end>0)
{
Swap(&a[0],&a[end]);
AjustDown(a,end,0);
--end;
}
}
堆排序是一个效率很好的排序其时间复杂度:O(nlogn)
3、交换排序
基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位 置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
类别:冒泡排序,快速排序
3.1冒泡排序
冒泡排序的思想非常简单,即记录一个元素的值并与它后面的元素比较。比如说是升序,如果后面的元素比记录值小,那就交换使记录值后移,直到整个序列遍历完,该记录值就会在合适的位置。然后就去记录下一个值继续比较。
3.1.1冒泡排序代码
void BubbleSort(int*a,int n )
{
int i,j;
for(i=0;i<n;i++)
{
for(j=i+1;j<n;j++)
{
if(a[i]>a[j])
{
Swap(&a[i],&a[j]);
}
}
}
}
代码中,a[i]就是一个记录值,j就是记录值后元素的下标,j的for循环就是将记录值后的元素与记录值进行比较,符合要求的就交换,j的for循环跑完后i++就会调到下一个数记录它的值,继续比较。
冒泡排序的时间复杂度:O(N^2)
3.2 快速排序
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值(key),按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
所以!
快速排序中,以key值为基准分割子序列的方法有:挖坑法,前后指针法,左右指针法。
三种方法的达到的目的相同,但分割出来的序列可能会不同,快速排序还有许多优化方式,以及非递归实现方式,本文因为篇幅原因,只介绍挖坑法和递归法。
快速排序的核心思想就是分治,将整个序列分割为两个子序列,两个子序列分别又继续分割,直到序列符合要求即问题不可再分割为止,此时整个序列就排好了。
3.2.1挖坑法处理序列
假如我们有序列“3,6,5,2,1,7,4”
用图来展示这个过程
说明:我们设置begin和end放在序列的左右两端 ,同时将pivot初始在begin的位置。为了方便,我们一般将key的值定为整个序列的首元素。移动end,将比key小的数赋给pivot的位置,并把pivot移动到当前end的位置;移动begin,然后,将比key大的数赋给pivot的位置,并把·pivot移动到当前begin的位置,循环往复直到begin与end相遇,此时pivot的位置就是key应该在的位置。
3.2.1.1挖坑法代码
int PartSort1(int *a,int left,int right)//挖坑法
{
int begin = left, end = right;
int pivot = begin;
int key = a[begin];
while (begin < end)
{
while (begin < end && a[end] >= key)
--end;
a[pivot] = a[end];
pivot = end;
while (begin < end && a[begin] <= key)
++begin;
a[pivot] = a[begin];
pivot = begin;
}
pivot = begin;
a[pivot] = key;
return pivot;
}
3.2.2用递归的方式处理剩下部分
第一次操作后partsort函数返回了坑位的下标,此时我们就可以把区间分割为: [left,pivot-1] pivot [pivot+1,right] 我们可以采取递归的方式分别处理两个区间
3.2.2.1递归
void QuickSort1(int *a,int left,int right)
{
if(left>=right)
{
return;
}
int pivot = PartSort1(a,left,right);
QuickSort1(a,left,pivot-1);
//因为第二次递归函数中传的是形式参数
//所以第二次递归的right并不会因为第一次递归而变化
QuickSort1(a,pivot+1,right);
}
说明:整个过程与二叉树的前序遍历有些相似;在第一次操作后,先操作左子区间直到区间左右边界重合为止,即问题不可再被分割。然后再去操作右子区间。下面展示函数的递归展开图便于理解。
假如我们有序列“4,6,7,1,2,5,3,9”,第一次操作后得到的序列为3 2 1 4 7 5 6 9
3.2.2.2递归展开图
这样的话,一个基本的快速排序就完成了。
快速排序的时间复杂度:平均:O(nlogn),最好:O(nlogn),最坏:顺序O(N^2),由此可见在一般情况下快速排序的效率很优良!!!
4、归并排序
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法 (Divide and Conquer)的一个非常典型的应用。
所谓的归并操作就是将两个有序的子序列合并为一个有序序列的操作(归并操作算法的时间复杂度为O(N+M),N+M分别为两个子数组的元素个数)
4.1操作逻辑:
4.1.1合并区间
假设一个序列“10,6,7,1,3,9,4,2”
1)我们对整个个数为N的序列进行严格的二分,再对子序列进行二分直到无法再被分割为止,序列被划分为N的独立单元。这样整个序列就构成了一个满二叉树的结构。
2)根据大小关系合并同一个父节点下的两个叶子节点。逐层往根节点合并,合并到根节点时,序列有序
归并操作的好处在于:它对序列的划分并不依据某个key值,而是严格地将序列一分为二,(无论什么序列)分割到最后都可以构成一棵满二叉树的结构。故归并排序的时间复杂度都是O(NlogN).
归并排序的劣势在于,它需要额外开辟数组来存放数据,对空间有较大的消耗故空间复杂度为O(N);
4.1.1.1归并排序合并区间代码
void _MergeSort(int*a ,int left,int right,int*tmp) { if(left >=right) { return; } int mid = (left+right) >> 1; int index =left;//子区间的左端 int begin1 = left,end1 = mid; int begin2 = mid+1,end2 = right; //合并区间 while(begin1<=end1&&begin2<=end2)//当有一情况不满足时都会退出循环 { if(a[begin1]<a[begin2])//取小的放在tmp数组的靠前位置 { tmp[index++] = a[begin1++]; } else { tmp[index++] = a[begin2++]; } } while(begin1<=end1)//当有一区间已经跑完时,剩下的区间的元素就是大的元素,此时就会直接放在数组中,有且只有一个数组会没跑完 { tmp[index++] = a[begin1++]; } while(begin2<=end2) { tmp[index++] = a[begin2++]; } for(int i=left;i<=right;i++) { a[i] = tmp[i]; } }
图示合并过程:拿排升序为例,每轮都把较小的那一个先放到数组中,然后移动begin接着下一轮,直到其中一个数组走完。此时另一个数组的元素是有序的且是较大的元素,所以就可以直接放进数组了
4.1.2递归归并排序(后序遍历)
我们使用递归对区间进行分割,直到分割到不可分割为止。然后我们再进行区间合并,后序遍历的目的就是为了实现我们先分割后合并的想法。
4.1.2.1递归代码
void _MergeSort(int*a ,int left,int right,int*tmp) { if(left >=right) { return; } int mid = (left+right) >> 1; //分割区间--------分解 _MergeSort(a,left,mid,tmp); _MergeSort(a,mid+1,right,tmp); //合并区间 ..................... }
其递归展开图同样是一棵满二叉树
4.1.3完整代码
void _MergeSort(int*a ,int left,int right,int*tmp) { if(left >=right) { return; } int mid = (left+right) >> 1; //分割区间--------分解 _MergeSort(a,left,mid,tmp); _MergeSort(a,mid+1,right,tmp); int index =left;//子区间的左端 int begin1 = left,end1 = mid; int begin2 = mid+1,end2 = right; //合并区间 while(begin1<=end1&&begin2<=end2)//当有一情况不满足时都会退出循环 { if(a[begin1]<a[begin2])//取小的放在tmp数组的靠前位置 { tmp[index++] = a[begin1++]; } else { tmp[index++] = a[begin2++]; } } while(begin1<=end1)//当有一区间已经跑完时,剩下的区间的元素就是大的元素,此时就会直接放在数组中,有且只有一个数组会没跑完 { tmp[index++] = a[begin1++]; } while(begin2<=end2) { tmp[index++] = a[begin2++]; } for(int i=left;i<=right;i++) { a[i] = tmp[i]; } } void MergeSort(int*a ,int n) { int *tmp = (int*)malloc(sizeof(int)*n); _MergeSort(a,0,n-1,tmp); free(tmp); }
注意:1.为什么index = left。index是指当前子区间的左端,为了不影响tmp数组中已排好的元素,index会随着子区间的变化而变化,取决于该层递归时传入函数的参数。整体上看就是尾插数据。
2.关于for循环中i的边界。为了保证数组不越界且尽可能地二分,在主函数中传入arrlen或arrlen-1时需要注意代码中是否会导致出错的情况。
5、计数排序
计数排序是一个十分特殊的排序方法,它不像其他排序方式一样需要多次比较数据。计数排序的基本思想为:找出序列中最大最小数;记录原数组中每个数据出现的次数,放在一个存放次数的数组中,而这个数组的下标就是元素的大小;然后按照下标与次数打印序列。
计数排序,适合应用在元素大小相差不多的序列.
计数排序的效率一般情况下都是比较优良的.
5.1操作逻辑
假设序列“100 105 108 103 104 109 107 105”
既然我们需要用一个数组来记录数据出现的个数,且该数组下标就是元素大小。但是如果数据很大,而数组又是从0开始计数的,这样的话就会造成较大的空间浪费。 因此我们需要相对映射元素的位置。
我们先找到了序列的最大值max和最小值min。可知数组的相对映射范围range = max-min+1;而元素相对映射位置(下标) = num - min
得到计数数组后,我们就可以根据相对映射位置,元素出现次数,相对映射范围来把序列填回原数组了
5.1.1计数排序代码
void CountSort(int *a,int n) { //相对映射位置:num-min = 下标 //100 105 108 103 104 109 107 // 0 5 8 3 4 9 7 int max = a[0],min = a[0]; //找出序列的最大最小值 for(int i =0;i<n;i++) { if(a[i]>max) { max = a[i]; } else if(a[i]<min) { min = a[i]; } } int range = max-min+1;//表示范围,并非元素个数 int *tmp = (int*)malloc(sizeof(int)*range); memset(tmp,0,sizeof(int)*range);//计数数组全部初始化为零, for(int i =0;i<n;i++)//遍历原数组,根据 下标 = num - min { tmp[a[i]-min]++;//记录次数 } //此时如果tmp中有元素为0就代表该处被认为是空 int j =0; for(int i=0;i<range;i++)//遍历计数数组 { while(tmp[i]>0) { a[j++] = i+min;//根据 num = 下标+min tmp[i]--;//减1直到0 } } free(tmp); }
时间复杂度: 计数排序的时间复杂度是O(n + k),其中n是要排序的元素个数,k是元素的最大值。这是因为它首先统计每个元素的频数(需要遍历一次),然后根据计数重构排序后的数组(线性的操作)。如果k远小于n,那么计数排序可以达到非常快的速度。
空间复杂度: 计数排序的空间复杂度主要取决于需要存储计数数组的大小。计数数组的长度等于元素的最大值+1,因此空间复杂度是O(k)。当k远小于n时,空间消耗较小。
总结
以上是博主在学习排序算法时的一些新的体会,希望大家可以有所收获,如有错误欢迎指出,也欢迎大家在评论区交流学习。
文中部分图片和内容来自互联网