排序算法再实践
前言
排序算法自始至终都是算法类的基础或者基石。所谓排序算法是一种能将一串数据依据特定排序方式的一种算法。可以根据不同方面将众多的排序算法进行归类,比如排序的稳定方面等。所谓稳定排序指的是,不管在排序前后,待排序的若干相等元素的相对位置是恒定不变,换句话来说就是,假设元素A与B元素值相等,同时排序前元素A先于元素B,那么在排序过程结束后,元素A也必须先于元素B,我们称这样的排序算法是稳定排序算法。下面是自己根据众多经典排序算法的特性总结出来的图表,通过此图,大家更能清楚明晰各个算法的部分差异处:
接下来,我们就从中挑选若干个经典的算法进行分析、实现、总结吧!
冒泡排序
冒泡排序是一种简单的排序算法,就地排序。实现原理也很简单:重复地遍历要排序的数列,一次比较两个元素,如果两个元素根据某种排序策略相对位置错误,那么就交换这两个元素的位置。遍历的操作重复进行直到不需要再进行任何交换,也就是说数列已经成为一种有序数列呢。
接下来我们通过图示的方式来说明冒泡排序的过程,以下是第一轮冒泡排序过程:
从上图我们可以看到,一轮冒泡排序过程是这样的:每次冒泡过程都是从数列的第一个元素开始,然后依次和剩余的元素进行比较,若小于相邻元素,则交换两者位置,同时将较大元素作为下一个比较的基准元素,继续将该元素与其相邻的元素进行比较,直到数列的最后一个元素。在具体的实现过程中,我们可以记录当前冒泡排序过程之前,数列中尾部已经排序好的最大元素里最小元素的位置。这样,当前冒泡排序只需要进行到该最小位置即可,减少不必要的比较过程,提高排序效率。在这里贴上维基百科上有关冒泡排序过程图,比较形象:
C/C++实现程序代码:
1: void bubbleSort(int arr[],int count){
2: int i,j,temp,tag;//tag用于记录当前冒泡排序进行比较的终止位置
3: tag=count-1;
4: for(i=0;i<count-1;i++){
5: for(j=0;j<tag;j++){
6: if(arr[j]>arr[j+1]){
7: temp=arr[j];
8: arr[j]=arr[j+1];
9: arr[j+1]=temp;
10: }
11: }
12: tag--;//下一轮排序进行比较的终止位置
13: }
14: }
从上述实现代码中,可以看到,我们使用了一个技巧,导致每次冒泡排序无需都比较到数列的最后元素。因为冒泡排序相对来说比较简单,属于入门级的简单排序算法,对它的剖析也就到此为止吧!接下来介绍插入排序。
插入排序
插入排序也是一种简单的排序算法,就地排序。实现原理也比较简单:通过构建有序数列,对于未排序数据,在已排序数列中从后往前扫描,找到相应位置进入插入即可。
接下来,我们通过示图来展示一轮插入排序的过程:
从上图中,我们可以清楚地看到,一次插入排序的操作过程:将待插元素,依次与已排序好的子数列元素从后到前进行比较,如果当前元素值比待插元素值大,则将移位到与其相邻的后一个位置,否则直接将待插元素插入当前元素相邻的后一位置,因为说明已经找到插入点的最终位置。
C/C++实现程序代码:
1: void insertSort(int arr[],int count){
2: int i,j,temp;
3: for(i=1;i<count;i++){
4: temp=arr[i];//保存待插入元素值,因为之后其所在位置会被覆盖
5: for(j=i-1;j>=0;j--){
6: if(temp<arr[j]){//小于时,则直接将当前元素后一位
7: arr[j+1]=arr[j];
8: }else{//大于或者等于时,则说明当前元素的后一位置即为插入点
9: arr[j+1]=temp;
10: break;
11: }
12: }
13: if(j==-1){//当待插入值为最小值时,直接插入在数列第一个位置
14: arr[0]=temp;
15: }
16: }
17: }
从上述实现代码来看,很容易看出其实现原理。因为是两重循环,故时间复杂度为O(n2),空间复杂度为O(1),最好时间复杂度是O(n),即当待排序数列本身已经是有序状态时。
选择排序
选择排序也是一种简单直观的排序算法,实现原理比较直观易懂:首先在未排序数列中找到最小元素,然后将其与数列的首部元素进行交换,然后,在剩余未排序元素中继续找出最小元素,将其与已排序数列的末尾位置元素交换。以此类推,直至所有元素圴排序完毕。
接下来,我们通过示图来展示选择排序的过程:
从上图中,我们可以清楚直观地看到选择排序的整个过程:每次都从未排序数列中选择最小元素,然后将其与未排序数列的起始位置元素进行交换,依此类推,直至所有元素全部有序排列。在这里贴上维基百科上有关冒泡排序过程图,比较形象:
C/C++实现程序代码:
1: void selectSort(int arr[],int count){
2: int i,j,min,temp;
3: for(i=0;i<count-1;i++){
4: min=i;
5: for(j=i+1;j<count;j++){
6: if(arr[min]>arr[j]){//如果当前元素小于最小元素,则修改min
7: min=j;
8: }
9: }
10: if(min!=i){//如果min不是未排序数列的起始位置,则与起始位置元素交换之
11: temp=arr[i];
12: arr[i]=arr[min];
13: arr[min]=temp;
14: }
15: }
16: }
从上述实现代码来看,整个实现思路与示图操作几乎一致,均是总是找寻当前未排序数列中最小元素,然后与未排序数列的起始位置元素进行交换之,直至所有元素均已有序。因为是使用了两重循环,所以时间复杂度为O(n2),空间复杂度为O(1)。同时,我们也可以看到,选择排序无所谓最好最坏情况,因为每次都需要在未排序数列中遍历一次查找最小元素。简单排序算法就介绍以上三种吧,接下来,我们将继续介绍较为复杂的排序算法。
快速排序
快速排序较上述三个简单排序算法有较明显的优势,平均时间复杂度为O(nlogn),既然性能更好,自然处理过程也就相对要复杂一些。但是只要理解了其原理,相信实现起来就就不会显得如此之难呢。大家要有信心。
快速排序原理是采用分治法策略将原数列拆分为两个前后子数列,算法实现步骤为:
- 从数列中挑选一个元素,称为“基准”元素。
- 重新排列数列,将比基准元素小的数列元素放于基准元素之前,将比基准元素大的数列元素放于基准之后,这样通过分区之后,基准元素就放于两部分的中间位置呢。这个过程称为分区操作。
- 递归地按照上述规则来排序第二步所分区出来的前后两部分子数列。
通过上述的处理过程,递归过程总会结束。因为在最底部的递归过程中,子数列个数总是为0或者为1。其实上,快速排序成功实现的关键点主要还是在于分区操作函数的编写。至于分区的思想,有多种。这里,我们主要要讲述来种比较经典的分区算法思路吧。
思路一:
选择待排数列的最后一个元素为基准元素,然后设置两个指针,假设为i和j指针。j指针从数列首部开始遍历直到数列的尾部倒数第二个元素,而i指针总是动态地指向已经被j指针遍历过的子数列中最后一个小于基准元素的元素,换句话来说就是i指针所指向的位置的下一个元素值就是当前子数列第一个大于基准元素的元素。这样,当j指针当前所指元素的值小于基准元素时,直接通过i指针就可以将(i+1)指针指向的元素与j指针指向的元素进行互换,同时将指针值 加一,指向下一位置,这样就完成了一次交换的过程。依此内推,直至指针j到达数列尾部倒数第二个元素为止。最后,直接将基准元素放于指针i所指位置的下一位置即可。这样就完成了分区过程。
接下来,我们通过示图来展示上述分区算法思路的过程:
该分区实现算法代码为:
1: int partition(int arr[],int left,int right){
2: int pivot=arr[right];//默认设置数列末尾元素为基准元素
3: int i,j,temp;
4: i=left-1;
5: for(j=left;j<right;j++){
6: if(arr[j]<=pivot){//当前元素小于基准元素时,将其与i+1位置的元素互换
7: i++;
8: temp=arr[j];
9: arr[j]=arr[i];
10: arr[i]=temp;
11: }
12: }
13: //将基准元素交换到中间位置
14: arr[right]=arr[++i];//i加一的目的是为了使i指向交换位置
15: arr[i]=pivot;
16: return i;
17: }
思路二:
选择待排数列的首部第一个元素为基准元素,设置两指针,分别指向数列首尾部位置,假设两指针分别设为i和j。每次遍历的过程是这样的,首先遍历指针j所指向的元素,直到j指向的元素值小于基准元素时,停止遍历,将其与指针i所指向的元素进行交换,因为当前指针所指位置就是用于插入较基准元素小的元素,然后再将指针i加一。接着轮到指针i遍历,直到i所指向的元素值大于基准元素时,停止遍历,将其与指针j所指向的元素进行交换,之所以可以交换,是因为指针j所指向的元素刚刚已经交换到前半部分呢,故可以直接选择覆盖就行,这样就将大于基准元素的元素放于后半部分。依此类推,直到指针i与指针相等或者大于时,停止外部循环。最后直接将基准元素直接放置于指针i所指向的位置即可,完成分区操作。
接下来,我们通过示图来展示上述分区算法思路的过程:
该分区实现算法代码为:
1: int partition(int arr[],int left,int right){
2: int pivot=arr[left];
3: int i=left,j=right;
4: while(i<j){
5: while(arr[j]>pivot&&i<j)//从后往前遍历直至出现第一个小于基准值的元素
6: j--;
7: if(i<j)
8: arr[i++]=arr[j];//当此时指针没有越界时,将arr[j]值置换到arr[i]中
9: while(arr[i]<pivot&&i<j)//从前往后遍历直至出现第一个大于基准值的元素
10: i++;
11: if(i<j)
12: arr[j--]=arr[i];//当此时指针没有越界时,将arr[i]值置换到arr[j]中
13: }
14: arr[i]=pivot;//最后将基准值放于前后两部分的中间位置
15: return i;//返回基准值所放的位置
16: }
以上便是两种经典的分区算法,在完成了分区算法的编写之后,接下来我们就可以很轻松地完成对快速排序算法的编写问题呢。通过分治策略,我们便可以比较容易地编写出代码,在这里贴上维基百科上有关快速排序过程图,比较形象:
C/C++实现程序代码:
1: void quickSort(int arr[],int left,int right){
2: int devision;
3: //以下调用的分区函数可以调用上述两个分区算法任何一个
4: devision=partition(arr,left,right);//将待排列数列分区为前后两部分,返回基准点位置
5: if(left<devision)//如果前半部分只有一个元素直接退出递归过程
6: quickSort(arr,left,devision-1);
7: if(devision<right)//如果后半部分只有一个元素直接退出递归过程
8: quickSort(arr,devision+1,right);
9: }
到这里,我们也差不多要完成了对快速排序算法的分析编写过程呢。从分析过程上来看,正确编写快速排序算法的关键还是在于对分区算法的编写,剩下的也就都是水到渠来呢。由于每次分区操作都将原数列分为前后两部分,理想条件下,前后两部分的元素个数相等,那么快速排序算法的运行时间的递归式为:T(n)=2T(n/2)+O(n),由算法导论主定理可知,T(n)近似等于O(nlogn)。最后贴出算法导论主定理内容:
设a>=1,b>1为常数,设f(n)为一函数,T(n)的递归式:T(n)=aT(n/b)+f(n),其中n/b指n/b的上取整或者是下取整。那么T(n)可能有如下的渐进界:
1)e>0,有f(n)=O(n^(log_b(a)-e)),则T(n)=O(n^log_b(a))
2)若f(n)=O(n^log_b(a)),则T(n)=O(n^log_b(a)*log(n))
3)e>0,有f(n)=O(n^(log_b(a)+e)),且对于c>1,有足够大的n满足af(n/b)<=cf(n),则T(n)=O(f(n)).
堆排序
堆排序是利用堆这种数据结构所设计的一种排序算法。堆是一种近似完全二叉树的结构,并同时满足堆的性质:即子节点的键值总是小于(或者大于)它的父结点键值。在这里,我们使用最大堆结构,也就是子节点键值总是小于父结点键值。而在具体实现中,我们通常使用数组来模拟堆的存储结构。有一点需要注意的是,我们将堆元素放于数组索引值为1开始的位置,也就是说不在索引值为0的位置放置元素,因为如此将索引0做为第一个元素放置点,那么其之后的左孩子结点的索引值都为0。堆的根结点(即存放堆中最大元素的位置)存放在数组索引一的位置,则:
- 父结点i的左孩子的索引为2*i
- 父结点i的右孩子的索引为2*i+1
- 子结点i的直接父结点的索引为i/2
在堆的数据结构中,最大堆的最大元素值总是放于根节点。堆中的定义的三大关键操作是:
- 最大堆调整(max_heapify):调整堆结构,使当前结点的所有子节点之间都符合最大堆结构
- 创建最大堆(build_Max_Heap):将堆中的所有元素进行重新排列,符合最大堆结构要求
- 堆排序(heapSort):将第一个根结点与最后一个结点进行互换,然后再调用最大堆调整算法进行递归操作
在这里贴上维基百科上有关快速排序过程图,比较形象:
接下来,我们就分别来介绍下上述所提到的三个最关键的堆操作算法吧!
首先是最大堆调整(max_heapify)操作。此函数实现思路比较直接,目的就是为了将“打乱”的堆结构重新调整为满足局部最大堆的结构特点。具体操作就是,在当前指定的根节点处,依次与子结点的键值进行比较,将其中较大键值元素调换到根节点,然后将调换后的子结点做为新的根结点,依此递归进行调换操作,直至到达叶子结点处,使当前局部节点元素处于一种满足最大堆结构特点的状态中。
接下来,我们通过示图来展示上述最大堆调整算法思路的过程:
上图中已经很清楚地展示了最大堆调整算法的过程,接下来,我们就直接贴出其具体的实现算法吧!需要注意的是,在实现过程中,存储堆元素的数组中索引为1的位置存储了堆结构中的第一个元素,而不是将第一个元素存储于索引为0的位置,这样做的目的主要是为了计算左孩子索引时方便,其他的操作与上述规定一致。
C/C++实现程序代码:
1: //数组arr索引0位置不存放元素,从索引1开始存放,方便求左右孩子,否则左孩子一直为0
2: //root从1开始,heapLength为堆的实际长度
3: void max_heapify(int arr[],int root,int heapLength){
4: int lchild=root*2,rchild=root*2+1,temp;
5: int max_site=root;//初始化最大值位置为root
6: if(lchild<=heapLength&&arr[lchild]>arr[root])
7: max_site=lchild;
8: if(rchild<=heapLength&&arr[rchild]>arr[max_site])
9: max_site=rchild;
10: if(max_site!=root){
11: temp=arr[root];
12: arr[root]=arr[max_site];
13: arr[max_site]=temp;
14: max_heapify(arr,max_site,heapLength);//递归处理余下的子结点元素
15: }
16: }
介绍完最大堆调整算法后,接下来,我们就将开始讲述创建最大堆(build_Max_Heap)操作算法呢。在已经实现了最大堆调整算法的基础上来完成此算法相对来说已经比较简单呢,因为在实现过程中只是反复调用最大堆调整算法的过程而已。我们只需要从第heapLength/2个堆元素开始,向前遍历到根元素(存放在数组中的第一个元素),每次遍历时都调用一次最大堆调整算法,将以当前堆元素为根节点的局部子树部分调整为最大堆结构。之所以从heapLength/2个堆元素开始,因为这是数组从后往前第一个不是叶子结点的元素,这样,经过最大堆调整算法处理后,整个堆结构就会满足最大堆结构特点呢。
接下来,我们通过示图来展示上述创建最大堆算法思路的过程:
从上图我们可以清楚地看到,所谓的创建最大堆操作就是多个最大堆调整算法的集合而已。从heapLength/2位置开始进行调整直至根元素。
C/C++实现程序代码:
1: //数组arr索引0位置不存放元素,从索引1开始存放,方便求左右孩子,否则左孩子一直为0
2: void build_Max_Heap(int arr[],int heapLength){
3: int i;
4: for(i=heapLength/2;i>=1;i--){
5: //从第heapLength/2个元素开始建最大堆,直到第一个元素
6: max_heapify(arr,i,heapLength);
7: }
8: }
完成了对创建最大堆算法的编写,最后我们只剩下对堆排序的算法介绍呢。原理很简单,在已经将堆结构调整为最大堆结构后,我们只需将根元素与最后的一个节点元素进行互换,然后再对新堆进行调整,使其符合最大堆结构,当然堆的长度减一,因为交换到最后位置的元素已经是最大元素,也就是说是其最后坐落位置呢。重复此操作,直至所有元素都已坐落在其最终位置上。这样就完成了堆排序过程呢。好了,原理就讲到这吧,相信已经比较清楚呢,直接上实现代码吧:
1: //数组arr索引0位置不存放元素,从索引1开始存放,方便求左右孩子,否则左孩子一直为0
2: void heapSort(int arr[],int heapLength){
3: int i,temp;
4: build_Max_Heap(arr,heapLength);
5: for(i=heapLength;i>0;i--){
6: temp=arr[1];
7: arr[1]=arr[heapLength];
8: arr[heapLength]=temp;
9: heapLength--;//堆的长度减一
10: max_heapify(arr,1,heapLength);//重新调整堆结构,使其符合最大堆定义
11: }
12: }
因为调整最大堆算法的时间复杂度为O(logn),所以很容易得出堆排序算法的平均时间复杂度为O(nlogn),又因为堆排序算法为就地排序,所以空间复杂度为O(1)。好呢,堆排序算法就介绍到这吧!接下来更精彩!
归并排序
归并排序或者合并排序是建立在归并基础上的一种有效的排序算法。和快速排序算法一样,归并排序算法也是采用分治法的一个典型应用。而所谓的分治策略是:将原问题划分成n个规模较小而结构与原问题相似的子问题;递归地解决这些子问题,然后再合并其结果,就可得到原问题的解。在这里多解几句,有关分治模式在每一层递归上都有三个步骤:
- 分解:将原问题分解成一系列子问题;
- 解决:递归地解各个子问题。若子问题足够小,则直接求解;
- 合并:将子问题的结果合并成原问题的解。
而合并排序算法完全依照了上述模式,直观地将操作分解如下:
- 将n个元素分成含n/2个元素的子序列;
- 用合并排序法对两个子序列递归地进行排序,在对子序列进行排序时,其长度为1时递归结束,因为间个元素视为有序;
- 合并两个已经排好序的子序列以得到排序结果。
与快速排序方法一样,最核心的操作并不是编写最外层的排序框架,而是内层真正完成排序的操作。在归并排序算法中,最核心的操作应该为合并操作(merge),即将两个已经有序的子序列进行合并成一个完整有序的数列。因为进行合并的两个子序列已经有序,故将其合并时,只需要从前往后依次比较两子序列元素大小,将较小元素插入,依此进行,直到所有元素都已经插入完毕,即完成了当前合并操作过程。
在这里贴上维基百科上有关归并排序过程图,比较形象:
接下来,我们通过示图来展示上述合并算法思路的过程:
上图已经将合并操作表示地很清楚呢,这里就不再啰嗦重敘呢。直接上实现代码吧!
1: //数组索引p到索引q位置的元素为已经排序好前半部分子数列,q+1到r为已经排序好的后半部分数列
2: void merge(int arr[],int p,int q,int r){
3: int llen=q-p+1;//前半部分的数组长度
4: int rlen=r-q;//后半部分的数组长度
5: int larr[llen];//用于临时装载前半部分的数组
6: int rarr[rlen];//用于临时装载后半部分的数组
7: int i,j,;
8: for(i=p,j=0;i<=q;i++,j++){
9: larr[j]=arr[i];
10: }
11: for(i=q+1,j=0;i<=r;i++,j++){
12: rarr[j]=arr[i];
13: }
14: i=j=0;
15: while(i!=llen&&j!=rlen){
16: if(larr[i]<rarr[j]){
17: arr[p]=larr[i++];
18: }else{
19: arr[p]=rarr[j++];
20: }
21: p++;//修改待插入位置
22: }
23: //插入还没有完全处理完的子序列元素,直接遍历插入即可
24: if(i==llen){
25: while(j!=rlen)
26: arr[p++]=rarr[j++];
27: }else{
28: while(i!=llen)
29: arr[p++]=larr[i++];
30: }
31: }
在完成了对合并操作算法的实现之后,接下来实现外层的合并排序框架也就水到渠来呢。原理就是上述讲的分治法,对子序列进行递归归并处理。实现代码如下:
1: void mergeSort(int arr[],int left,int right){
2: int p,q,r;
3: p=left;q=(left+right)/2;r=right;
4: if(left<right){
5: mergeSort(arr,p,q);//对前半部分子序列递归地进行兼并处理
6: mergeSort(arr,q+1,r);//对后半部分子序列递归地进行兼并处理
7: merge(arr,p,q,r);//对前后两部分已经排好序的子序列进行合并操作
8: }
9: }
由于使用到了分治法,并且合并操作时间复杂度为O(n),则运行时间应该为T(n)=2T(n/2)+O(n),根据介绍的主定理,可以得到时间复杂度近似为:O(nlogn)。而空间复杂度很明显为O(n),因为我们使用了一个辅助数组存储空间来协助进行合并操作。
其他排序算法
到这里,我们基本上把所有大众排序算法都已经介绍和实现呢,剩下的还有一些不怎么常用的排序算法或者说实现原理相似排序算法。比如希尔排序,在确定好步长之后,主要的操作和插入排序算法完全一样,但是时间复杂度为O(nlogn),空间复杂度为O(1)。再有就是桶排序,原理是将阵列分到有限数量的桶子里。每个桶子再个别排序(有可能再使用别的排序算法或是以递回方式继续使用桶排序进行排序)。当要被排序的阵列内的数值是均匀分配的时候,桶排序使用线性时间O(n)。最后就是基数排序,原理是将整数按位数切割成不同的数字,然后按每个位数分别比较,时间复杂度是 O(k*n),其中n是排序元素个数,k是数字位数。
结束语
使用了一个礼拜的时间完成了对通用排序算法的整理和实现,希望自己重温基础的同时也能给各位童鞋有所启示。算法并不是那么难,尤其是通用算法,只要肯下苦功夫,相信没有啃不下的“难题”。到这里,该博文也到收尾的时候呢。与君共勉吧!