以下是个人对常用排序的一些总结,参考了邓俊辉老师编写的《数据结构(C++语言版)(第三版)》和网络上一些大神的博客。
个人水平有限,若有错误请留言指出,万分感激!
以下给出各种算法的时间复杂度和空间复杂度,以及稳定性。
排序算法的稳定性:通俗地讲就是能保证排序前2个相等的数其在序列的前后位置顺序和排序后它们两个的前后位置顺序相同。在简单形式化一下,如果Ai = Aj, Ai原来在位置前,排序后Ai还是要在Aj位置前。其次,说一下稳定性的好处。排序算法如果是稳定的,那么从一个键上排序,然后再从另一个键上排序,第一个键排序的结果可以为第二个键排序所用。基数排序就 是这样,先按低位排序,逐次按高位排序,低位相同的元素其顺序再高位也相同时是不会改变的。更多有关排序算法的稳定性可参见百科。
以下swap函数可以用STL中的,也可以是自定义,自定义最好修改函数名。本文中使用STL中swap函数。
一、交换排序
1、冒泡排序
思想:比较相邻的元素,若它们顺序逆序,则交换。反复遍历要排序的元素,直到没有逆序元素对为止,这个算法中元素移动的过程,犹如气泡在水中的上下沉浮,气泡算法(bubblesort)也因此得名。对n各元素,过程如下:
1、遍历元素,比较相邻元素,若发现逆序则交换两者位置,直到最大的元素被交换到最后;
2、重新从头开始遍历[0,n-1)个元素,直到第二大元素在倒数第二个;
3、依次重复遍历元素,直到没有逆序的元素对。
代码如下:
1 void bubblesort(int A[],int n) 2 { 3 //int n=sizeof(A)/sizeof(int); n为数组长度 4 for(int i=0;i<n-1;++i) 5 { 6 for(int j=0;j<n-1-i;++j) 7 { 8 if(A[j]>A[j+1]) 9 swap(A[j],A[j+1]); 10 } 11 } 12 }
另一种写法:(其他算法也可以改成这种形式)
1 void bubbleSort(int *A,int n) 2 { 3 for(int i=0;i<n-1;i++) 4 { 5 for(int j=0;j<n-1-i;++j) 6 { 7 if(A[j]>A[j+1]) 8 swap(&A[j],&A[j+1]); 9 } 10 } 11 }
扩展:
在第i次扫描中,若元素都已经排好序了,但按照上面的代码还要继续完成剩下的n-1次排序,这样会浪费一定时间。解决办法是定义一个bool变量sorted,表示在某次交换中是否出现元素对交换的情况。若有这后面要接着扫描,若没有则直接跳出就行。代码如下:
1 void bubblesort(int A[],int n) 2 { 3 bool sorted=false; 4 for(int i=0;i<n-1;++i) 5 {
sorted=false; 6 for(int j=0;j<n-1-i;++j) 7 { 8 if(A[j]>A[j+1]) 9 { 10 sorted=true; 11 swap(A[j],A[j+1]); 12 } 13 } 14 if(sorted==false) 15 break; 16 } 17 }
注:两个代码块中的第八行代码为if(A[j]>A[j+1]),即当出现A[i]=A[i+1]时,两相等元素的相对位置是不会交换的,所以冒泡排序是稳定排序。
另外,两代码中内层的for循环也可以从后往前。
冒泡排序的改进----定向冒泡排序(也成鸡尾酒排序)
(参考SteveWang的博客)常规的冒泡排序,每次都是 从低到高的去选取元素排在后面,二定向冒泡排序则是,先从前往后将最大值放在最后,然后从后往前将最小值放在最前面 。代码如下:
1 void bubblesort1(int A[],int n) 2 { 3 int left=0,right=n-1; 4 while(left<right) 5 { 6 for(int i=left;i<right;i++) 7 { 8 if(A[i]>A[i+1]) 9 swap(A[i],A[i+1]); 10 } 11 right--; 12 for(int i=right;i>left;i--) 13 { 14 if(A[i-1]>A[i]) 15 swap(A[i-1],A[i]); 16 } 17 left++; 18 } 19 }
以序列(2,3,4,5,1)为例,鸡尾酒排序只需要访问一次序列就可以完成排序,但如果使用冒泡排序则需要四次。但是在乱数序列的状态下,鸡尾酒排序与冒泡排序的效率都很差劲。也是稳定的排序算法。
2、快速排序
平均情况下,对n各元素进行排序要O(nlogn)次比较, 最好为O(n),最差退化为O(n^2)。
基本思想:通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据都比另一部分的所有数据要小。然后再按此方法对这两部分数据分别进行排序排序,整个过程可以递归进行,以此达到整个数据变成有序序列。
过程如下:
i)从序列中跳出一个元素,作为“基准”(pivot);
ii)把所有比基准小的元素放在基准前面,所有比基准小的元素放在基准后面(相同元素可放在任一边)。(分区操作partition);
iii)递归上面两步。
代码如下:
1 int partition(int A[],int left,int right) 2 { 3 int pivot=A[right]; //基准 4 int i=left-1; 5 for(int j=left;j<right;j++) 6 { 7 if(A[j]<=pivot) 8 { 9 i++; 10 swap(A[i],A[j]); 11 } 12 } 13 swap(A[i+1],A[right]); 14 return i+1; 15 } 16 17 /*left,right为下标值,如{2,8,7,1,3,5,6,4}中left=0,right=7 */ 18 void quicksort(int A[],int left,int right) 19 { 20 int i; //pivot_index 基准索引 21 if(left<right) 22 { 23 i=partition(A,left,right); 24 quicksort(A,left,i-1); 25 quicksort(A,i+1,right); 26 } 27 }
以《算法导论》中的例子进行讲解。例:{2,8,7,1,3,5,6,4}的一次partition过程。
绿色区域是小于基元的,蓝色区域是大于基元的,白色区域为未比较区。
大致过程如:选取最后一个作为基准,从左往右遍历,如图(a)。图(b),2小于4,则2与其自身交换,放入绿色区域;图(c)、(d)中8和7都大于基准4,所以将其放入蓝色区域(即指针j继续向右遍历);图(e),3小于4,则3和7交换,将3放入绿色区域,得到图(f);图(g)、(h)中5和6都大于基准4,所以将它们放入蓝色区域(分别j++即可);此时j=right,所以跳出循环,然后将基准4和指针i的下一个元素(即8)交换即可,这样就完成了一次分区操作。
快速排序是不稳定的排序算法,不稳定发生在基准元素与A[tail+1]交换的时刻。
比如序列:{ 1, 3, 4, 2, 8, 9, 8, 7, 5 },基准元素是5,一次划分操作后5要和第一个8进行交换,从而改变了两个元素8的相对次序。
还有一种,使用两指针从元素序列两端开始向中间排除的写法。主要思想是:以第一个元素为基准,先从右往左找到小于基准的数,然后从左往右找到大于基准的数,两者交换,直到两指针相遇,将相遇处的值和基准交换。文章地址。
二、选择排序
1、直接选择排序
该算法将元素划分为有序前缀和无序后缀,此外要求前缀不大于后缀。如此,每次只需从后缀中选出最小者,并作为最大元素转移至前缀中(后缀中的最小元素,前缀中的最大元素),即可使有序部分的范围不断扩张。(也可以采用将最大值移到最后的方式)
和冒泡排序的区别是:选择排序,是找到无序后缀中的最小值后,和无序后缀中的第一个交换,即只交换一次。而冒泡排序通过相邻元素两两交换实现将最大值放在最后。(也可以按照从大到小的顺序排)
代码如下:
1 void selectsort(int A[], int n) 2 { 3 int i, j, min; 4 for (i = 0; i<n - 1; i++) 5 { 6 min = i; 7 for (j = i + 1; j<n; j++) 8 { 9 if (A[j]<A[min]) 10 min = j; 11 } 12 if (min != i) 13 swap(A[min],A[i]); 14 } 15 }
选择排序是不稳定算法,如序列:{ 5, 8, 5, 2, 9 },一次选择的最小元素是2,然后把2和第一个5进行交换,从而改变了两个元素5的相对次序。
有关考虑改进为每趟循环确定两个元素(当前趟最大和最小记录)的位置,从而减少排序所需的循环次数,参加叶子美美博客。
2、堆排序
(二叉)堆是一个数组,它可以被看成一个近似的完全二叉树。
对结点i,若有左孩子,则左孩子的下标为:2*i+1;若有右孩子,则右孩子的下标为2*i+2;若有父结点,则父结点下标为:(i-1)/2;
二叉堆分为两种形式,最大堆和最小堆。最大堆性质是指除根以外的所有结点i都要满足A[PARENT(i)]>=A[i],也就是说某结点的值至多与其父节点一样大,因此,堆中的最大元素放在根节点中。并且,在任一子树中,该子树所包含的所有结点的值都不大于该子树根结点的值。最小堆的组织方式正好相反,最小堆性质是指除了根以外的所有结点,都有A[PARENT(i)]<=A[i]。
堆排序的过程如下:
i)创建一个堆;
ii)把堆顶元素(最大值)和堆尾元素互换;
iii)把堆的尺寸缩小1,并调用heapFy(A,0)从新的堆顶元素开始进行堆调整;
iv)重复ii),直到堆的大小为1.
1 void heapFy(int A[], int i, int heapSize) 2 { 3 int lChild = 2 * i + 1; 4 int rChild = 2 * i + 2; 5 int largest; 6 if (lChild<heapSize&&A[lChild]>A[i]) 7 largest = lChild; 8 else 9 largest = i; 10 if (rChild<heapSize&&A[rChild]>A[largest]) 11 largest = rChild; 12 13 if (largest != i) 14 { 15 swap(A[i], A[largest]); 16 heapFy(A, largest,heapSize); 17 } 18 19 } 20 21 void buildHeap(int A[], int n) 22 { 23 for (int i = n / 2 - 1; i >= 0; i--) 24 { 25 heapFy(A, i,n); 26 } 27 } 28 29 void heapSort(int A[], int n) 30 { 31 int heapSize = n; 32 buildHeap(A, n); 33 for (int i = n - 1; i>0; i--) 34 { 35 swap(A[0], A[i]); 36 heapSize--; 37 heapFy(A, 0, heapSize); 38 } 39 }
注:建堆过程中,i从n/2-1开始,是因为下标在其后的都是树的叶节点。每个结点都可以看成只包含一个元素的堆。
三、插入排序
1、直接插入排序
整体思路:从后往前扫描已排好的元素,与未排序的元素(目标元素)比较,找到相应的位置并插入。每找到一个位置都要该位置和目标元素之间的所有元素向后移动一个单位,并将目标元素插入指定位置。反复执行直到最后。
过程如下:
i)第一个元素认为是已经排好的,不动;
ii)取下一个元素(目标),在已排序的元素序列中从后向前扫描;
iii)如果已排序中元素大于目标元素,则将该元素后移一位;
iv)重复第iii)步,直到已排序的元素不大于目标元素;
v)将目标元素插入该位置。
代码如下:
1 void insertionSort(int A[],int n) 2 { 3 int i,j,temVal=0; 4 for(i=1;i<n;i++) 5 { 6 temVal=A[i]; 7 j=i-1; 8 while(j>=0&&A[j]>temVal) 9 { 10 A[j+1]=A[j]; 11 j--; 12 } 13 A[j+1]=temVal; 14 } 15 }
直接插入算法是稳定算法。不过不适合对数据较大的排序应用。sort采用的是成熟的"快速排序算法"(目前大部分STL版本已经不是采用简单的快速排序,而是结合内插排序算法)。参见STL所有sort算法介绍。
2、希尔排序
希尔排序是插入排序的一种。也称缩小增量排序,是插入排序的一种更高效的改进版。
1 void shellSort(int A[],int n) 2 { 3 int step,i,j,temp; 4 step=n/2; 5 while(step>=1) 6 { 7 for(i=step;i<n;++i) 8 { 9 temp=A[i]; 10 j=i-step; 11 while(j>=0&&A[j]>temp) 12 { 13 A[j+step]=A[j]; 14 j-=step; 15 } 16 A[j+step]=temp; 17 } 18 step/=2; 19 } 20 }
宏观上,以每一个step为一组,如:{9,6,3,4,5,7},step=6/2=3,有三组:{9,4}、{6,5}、{3,7},在每组内使用插入排序。通过下标连接组的概念,没有实体分组。
以{9,6,3,4,5,7}来说明整个算法的具体过程。
刚开始时,step=3,则相互比较,以后得到{4,5,3,9,6,7};当step=1时,4<5,不用插入排序;当遇到5>3时,先用5更新下标为2处的值,然后继续while循环,4和3比较,则用4更新下标为1处的值;此时j-step以后小于0,所以只需将3赋给下标为0处。剩下的同理可得。
(SteveWang博客的解释)希尔排序是不稳定算法。 虽然一次插入排序是稳定的,不会改变相同元素的相对顺序,但在不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱。比如序列:{ 3, 5, 10, 8, 7, 2, 8, 1, 20, 6 },h=2时分成两个子序列 { 3, 10, 7, 8, 20 } 和 { 5, 8, 2, 1, 6 } ,未排序之前第二个子序列中的8在前面,现在对两个子序列进行插入排序,得到 { 3, 7, 8, 10, 20 } 和 { 1, 2, 5, 6, 8 } ,即 { 3, 1, 7, 2, 8, 5, 10, 6, 20, 8 } ,两个8的相对次序发生了改变。
四、归并排序
归并排序是采用分治法(Divide and Conquer)的一个非常典型的应用。归并排序算法主要依赖归并(merge)操作,即,将两个已排好的序列合并成一个序列。
归并操作的算法描述(更多参见百科):
i)申请空间,使其大小为两已排序序列之和,该空间用来存放合并后的序列;
ii)设定两指针,最初位置分别为两个已排序序列的起始位置;
iii)比较两个指针所指向的元素,选择相对较小值放入合并空间中,并移动指针到下一位置;
iv)重复iii 1 void merge(int A[], int left, int mid, int right)。
代码如下:
1 void merge(int A[], int left, int mid, int right) 2 { 3 int len = right - left + 1; 4 int *T = new int[len]; //辅助空间O(n) 5 int index = 0; //新数组下标 6 int i = left; //i、j分别指向两数组的开始 7 int j = mid + 1; 8 while (i<=mid&&j <= right) 9 { 10 T[index++] = A[i] <= A[j] ? A[i++] : A[j++]; 11 } 12 /*当其中有一个没有遍历到结尾*/ 13 while (i <= mid) 14 { 15 T[index++] = A[i++]; 16 } 17 while (j <= right) 18 { 19 T[index++] = A[j++]; 20 } // 21 22 for (int k = 0; k<len; k++) 23 { 24 A[left++] = T[k]; 25 } 26 delete T; 27 } 28 29 void mergeSort(int A[], int left, int right) 30 { 31 if (left<right) 32 { 33 int mid = (left + right) / 2; 34 mergeSort(A, left, mid); 35 mergeSort(A, mid + 1, right); 36 merge(A, left, mid, right); 37 } 38 }
归并排序算法,主要两件事:
第一: “分”, 就是将待排序序列尽可能的分,直到一个个元素为一个序列;
第二: “并”,将序列两两合并排序,直到最后。
归并排序算法是稳定算法。不是就地排序算法,需要开辟新的空间。
参考:
http://www.cnblogs.com/biyeymyhjob/archive/2012/07/17/2591457.html(博客中有关于排序算法选择性的讲解)
http://www.cnblogs.com/eniac12/p/5329396.html#s6 (博客中有各种算法的动态演示)
https://segmentfault.com/a/1190000002595152#articleHeader19
http://www.cnblogs.com/yyangblog/archive/2010/12/29/1920816.html
http://blog.csdn.net/iamfranter/article/details/6825207# (算法选择讲解)