八大排序算法
排序有内部排序和外部排序之分,内部排序是数据记录在内存中进行排序,而外部排序是因排序的数据很大,一次不能容纳全部的排序记录,在排序过程中需要访问外存。我们这里说的八大排序算法均为内部排序。
下图为排序算法体系结构图:
1. 直接插入排序(Straight Insertion Sort )
基本思想:将待排序的无序数列看成是一个仅含有一个元素的有序数列和一个无序数列,将无序数列中的元素逐次插入到有序数列中,从而获得最终的有序数列。
算法流程:
1)初始时, a[0]自成一个有序区, 无序区为a[1, ... , n-1], 令i=1;
2)将a[i]并入当前的有序区a[0, ... , i-1];
3)i++并重复2)直到i=n-1, 排序完成。
时间复杂度:O(n^2)。
示意图:初始无序数列为 49, 38, 65, 97, 76, 13, 27 ,49
说明:如果碰见一个和插入元素相等的,那么插入元素把想插入的元素放在相等元素的后面。所以,相等元素的前后顺序没有改变,从原无序序列出去的顺序就是排好序后的顺序,所以插入排序是稳定的。
C++实现源码:
//直接插入排序,版本1 void StraightInsertionSort1(int a[], int n) { int i, j, k; for(i=1; i<n; i++) { //找到要插入的位置 for(j=0; j<i; j++) if(a[i] < a[j]) break; //插入,并后移剩余元素 if(j != i) { int temp = a[i]; for(int k=i-1; k>=j; k--) a[k+1] = a[k]; a[j] = temp; } } PrintDataArray(a, n); }
两种简化版本,推荐第三版本。
//直接插入法,版本2:搜索和后移同时进行 void StraightInsertionSort2(int a[], int n) { int i, j, k; for(i=1; i<n; i++) if(a[i] < a[i-1]) { int temp = a[i]; for(j=i-1; j>=0 && a[j]>temp; j--) a[j+1] = a[j]; a[j+1] = temp; } PrintDataArray(a, n); } //插入排序,版本3:用数据交换代替版本2的数据后移(比较对象只考虑两个元素) void StraightInsertionSort3(int a[], int n) { for(int i=1; i<n; i++) for(int j=i-1; j>=0 && a[j]>a[j+1]; j--) Swap(a[j], a[j+1]); PrintDataArray(a, n); }
2. 希尔排序(Shells Sort)
希尔排序是1959 年由D.L.Shell 提出来的,相对直接排序有较大的改进。希尔排序又叫缩小增量排序
基本思想:先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录“基本有序”时,再对全体记录进行依次直接插入排序。
算法流程:
1)选择一个增量序列t1,t2,…,tk,其中ti>tj,tk=1;
2)按增量序列个数k,对序列进行k 趟排序;
3)每趟排序,根据对应的增量ti,将待排序列分割成若干长度为m 的子序列,分别对各子表进行直接插入排序。仅增量因子为1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。
时间复杂度:O(n^(1+e))(其中0<e<1),在元素基本有序的情况下,效率很高。希尔排序是一种不稳定的排序算法。
希尔排序的示例:
C++实现源码:
//希尔排序
void ShellSort(int a[], int n) { int i, j, gap; //分组 for(gap=n/2; gap>0; gap/=2) //直接插入排序 for(i=gap; i<n; i++) for(j=i-gap; j>=0 && a[j]>a[j+gap]; j-=gap) Swap(a[j], a[j+gap]); PrintDataArray(a, n); }
通过源代码我们也能看出来,希尔排序就是在直接插入排序的基础上加入了分组策略。
3. 直接选择排序(Straight Selection Sort)
基本思想:在要排序的一组数中,选出最小(或者最大)的一个数与第1个位置的数交换;然后在剩下的数当中再找最小(或者最大)的与第2个位置的数交换,依次类推,直到第n-1个元素(倒数第二个数)和第n个元素(最后一个数)比较为止。
算法流程:
1)初始时,数组全为无序区a[0, ... , n-1], 令i=0;
2)在无序区a[i, ... , n-1]中选取一个最小的元素与a[i]交换,交换之后a[0, ... , i]即为有序区;
3)重复2),直到i=n-1,排序完成。
时间复杂度分析:O(n^2),直接选择排序是一种不稳定的排序算法。
直接选择排序的示例:
C++实现源码:
//直接选择排序 void StraightSelectionSort(int a[], int n) { int i, j, minIndex; for(i=0; i<n; i++) { minIndex=i; for(j=i+1; j<n; j++) if(a[j]<a[minIndex]) minIndex=j; Swap(a[i], a[minIndex]); } PrintDataArray(a, n); }
4. 堆排序(Heap Sort)
堆排序是一种树形选择排序,是对直接选择排序的有效改进。
堆的定义如下:具有n个元素的序列(k1,k2,...,kn),当且仅当满足
时称之为堆。由堆的定义可以看出,堆顶元素(即第一个元素)必为最小项(小顶堆)。
若以一维数组存储一个堆,则堆对应一棵完全二叉树,且所有非叶结点的值均不大于(或不小于)其子女的值,根结点(堆顶元素)的值是最小(或最大)的。如:
(a)大顶堆序列:(96, 83,27,38,11,09)
(b)小顶堆序列:(12,36,24,85,47,30,53,91)
基本思想:初始时把要排序的n个数的序列看作是一棵顺序存储的二叉树(一维数组存储二叉树),调整它们的存储序,使之成为一个堆,将堆顶元素输出,得到n 个元素中最小(或最大)的元素,这时堆的根节点的数最小(或者最大)。然后对前面(n-1)个元素重新调整使之成为堆,输出堆顶元素,得到n 个元素中次小(或次大)的元素。依此类推,直到只有两个节点的堆,并对它们作交换,最后得到有n个节点的有序序列。称这个过程为堆排序。
时间复杂度分析:O(nlog(n)),堆排序是一种不稳定的排序算法。
因此,实现堆排序需解决两个问题:
1. 如何将n 个待排序的数建成堆?
2. 输出堆顶元素后,怎样调整剩余n-1 个元素,使其成为一个新堆?
首先讨论第二个问题:输出堆顶元素后,怎样对剩余n-1元素重新建成堆?
调整小顶堆的方法:
1)设有m 个元素的堆,输出堆顶元素后,剩下m-1 个元素。将堆底元素送入堆顶((最后一个元素与堆顶进行交换),堆被破坏,其原因仅是根结点不满足堆的性质。
2)将根结点与左、右子树中较小元素的进行交换。
3)若与左子树交换:如果左子树堆被破坏,即左子树的根结点不满足堆的性质,则重复方法 (2).
4)若与右子树交换,如果右子树堆被破坏,即右子树的根结点不满足堆的性质。则重复方法 (2).
5)继续对不满足堆性质的子树进行上述交换操作,直到叶子结点,堆被建成。
称这个自根结点到叶子结点的调整过程为筛选。如图:
再讨论第一个问题,如何将n 个待排序元素初始建堆?
建堆方法:对初始序列建堆的过程,就是一个反复进行筛选的过程。
1)n 个结点的完全二叉树,则最后一个结点是第n/2个结点的子树。
2)筛选从第n/2个结点为根的子树开始,该子树成为堆。
3)之后向前依次对各结点为根的子树进行筛选,使之成为堆,直到根结点。
如图建堆初始过程:无序序列:(49,38,65,97,76,13,27,49)
C++实现源码:
//堆排序问题二:如何调整一个堆? void HeapAdjusting(int a[], int root, int n) { int temp = a[root]; int child = 2*root+1; //左孩子的位置 while(child<n) { //找到孩子节点中较小的那个 if(child+1<n && a[child+1]<a[child]) child++; //如果较大的孩子节点小于父节点,用较小的子节点替换父节点,并重新设置下一个需要调整的父节点和子节点。 if(a[root]>a[child]) { a[root] = a[child]; root = child; child = 2*root+1; } else break; //将调整前父节点的值赋给调整后的位置。 a[root] = temp; } } //堆排序问题一:如何初始化建堆? void HeapBuilding(int a[], int n) { //从最后一个有孩子节点的位置开始调整,最后一个有孩子节点的位置为(n-1)/2 for(int i=(n-1)/2; i>=0; i--) HeapAdjusting(a, i, n); } //堆排序 void HeapSort(int a[], int n) { //初始化堆 HeapBuilding(a, n); //从最后一个节点开始进行调整 for(int i=n-1; i>0; i--) { //交换堆顶元素和最后一个元素 Swap(a[0], a[i]); //每次交换后都要进行调整 HeapAdjusting(a, 0, i); } }
在这里多说几句堆排序的强大之处,堆排序可以看成是一种算法,也可以看成是一种数据结构。它可以分为小顶堆和大顶堆。与堆这种数据结构联系紧密的一种典型问题就是我们经常遇到的top-K问题。
我们先看一个大数据top-K示例:
例子:搜索引擎会通过日志文件把用户每次检索使用的所有检索串都记录下来,每个查询串的长度为1-255字节。假设目前有一千万个记录(这些查询串的重复度比较高,虽然总数是1千万,但如果除去重复后,不超过3百万个。一个查询串的重复度越高,说明查询它的用户越多,也就是越热门。),请你统计最热门的10个查询串,要求使用的内存不能超过1G。
首先,我们知道这是一个典型的top-K问题。
针对大数据问题进行统计首先应该想到的就是Hash_map。所以第一步就是先遍历全部的1千万Query,构建出一个大小为3百万的Hash_map,其中的key值为某条Query,对应的value值为该条Query的查询次数。
建好Hash_map以后,我们接下来的问题就是如何在3百万的Query中找出10个最热门的Query,也就是要用到排序算法。排序算法中效率最高的时间复杂度为O(n*log(n)),这是最简单粗暴的方法,也是最直接的方法。或者我们进一步优化,该题目是要求寻找top-K问题,那么我们可以直接去前K个Query构建一个数组,然后对其进行排序。遍历剩余的全部Query,如果某条Query的查询次数大于数组中最小的一个,将数组中最小的Query剔除,加入这条新的Query。接着调整数组顺序,依次进行遍历,这样的最坏情况下的复杂度为O(n*K)。
但是还可以继续优化寻找top-K的操作,那就是借助小根堆来实现。基于以上的分析,我们想想,有没有一种既能快速查找,又能快速移动元素的数据结构呢?回答是肯定的,那就是堆。
具体过程是,堆顶存放的是整个堆中最小的数,现在遍历N个数,把最先遍历到的k个数存放到最小堆中,并假设它们就是我们要找的最大的k个数,X1>X2...Xmin(堆顶),而后遍历后续的(n-K)个数,一一与堆顶元素进行比较,如果遍历到的Xi大于堆顶元素Xmin,则把Xi放入堆中,而后更新整个堆,更新的时间复杂度为logK,如果Xi<Xmin,则不更新堆,整个过程的复杂度为O(K)+O((N-K)*logK)=O(N*logK)。
一个有关小根堆解决top-K问题的小动画,请点击这个链接。
思想与上述算法二一致,只是算法在算法三,我们采用了最小堆这种数据结构代替数组,把查找目标元素的时间复杂度有O(K)降到了O(logK)。那么这样,采用堆数据结构,算法三,最终的时间复杂度就降到了O(n*logK),和算法二相比,又有了比较大的改进。
5. 冒泡排序(Bubble Sort)
基本思想:在要排序的一组数中,对当前还未排好序的范围内的全部数,自上而下对相邻的两个数依次进行比较和调整,让较大的数往下沉,较小的往上冒。即:每当两相邻的数比较后发现它们的排序与排序要求相反时,就将它们互换。每一趟排序后的效果都是讲没有沉下去的元素给沉下去。
算法流程:
1)比较相邻的两个元素,如果前面的数据大于后面的数据,就将两个数据进行交换;这样对数组第0个元素到第n-1个元素进行一次遍历后,最大的一个元素就沉到数组的第n-1个位置;
2)重复第2)操作,直到i=n-1。
时间复杂度分析:O(n^2),冒泡排序是一种不稳定排序算法。
冒泡排序的示例:
C++实现源码:
//冒泡排序 void BubbleSort(int a[], int n) { int i, j; for(i=0; i<n; i++) //j的起始位置为1,终止位置为n-i for(j=1; j<n-i; j++) if(a[j]<a[j-1]) Swap(a[j-1], a[j]); PrintDataArray(a, n); }
6. 快速排序(Quick Sort)
基本思想:快速排序算法的基本思想为分治思想。
1)先从数列中取出一个数作为基准数;
2)根据基准数将数列进行分区,小于基准数的放左边,大于基准数的放右边;
3)重复分区操作,知道各区间只有一个数为止。
算法流程:(递归+挖坑填数)
1)i=L,j=R,将基准数挖出形成第一个坑a[i];
2)j--由后向前找出比它小的数,找到后挖出此数a[j]填到前一个坑a[i]中;
3)i++从前向后找出比它大的数,找到后也挖出此数填到前一个坑a[j]中;
4)再重复2,3),直到i=j,将基准数填到a[i]。
时间复杂度:O(nlog(n)),但若初始数列基本有序时,快排序反而退化为冒泡排序。
快速排序的示例:
(a)一趟排序的过程:
(b)排序的全过程
C++实现源码:
//快速排序 void QuickSort(int a[], int L, int R) { if(L<R) { int i=L, j=R, temp=a[i]; while(i<j) { //从右向左找小于基准值a[i]的元素 while(i<j && a[j]>=temp) j--; if(i<j) a[i++]=a[j]; //从左向右找大于基准值a[i]的元素 while(i<j && a[i]<temp) i++; if(i<j) a[j--]=a[i]; } //将基准值填入最后的坑中 a[i]=temp; //递归调用,分治法的思想 QuickSort(a, L, i-1); QuickSort(a, i+1, R); } }
7. 归并排序(Merge Sort)
基本思想:归并(Merge)排序法是将两个(或两个以上)有序表合并成一个新的有序表,即把待排序序列分为若干个子序列,每个子序列是有序的。然后再把有序子序列合并为整体有序序列。
算法流程:(迭代+两个有序数列合并为一个有序数列)
时间复杂度:O(nlog(n)),归并算法是一种稳定排序算法。
归并排序示例:
C++实现源码:
//merge两个有序数列为一个有序数列 void MergeArr(int a[], int first, int mid, int last, int temp[]) { int i = first, j = mid+1; int m = mid, n = last; int k=0; //通过比较,归并数列a和b while(i<=m && j<=n) { if(a[i]<a[j]) temp[k++] = a[i++]; else temp[k++] = a[j++]; } //将数列a或者b剩余的元素直接插入到新数列后边 while(i<=m) temp[k++] = a[i++]; while(j<=n) temp[k++] = a[j++]; for(i=0; i<k; i++) a[first+i] = temp[i]; } //归并排序 void MergeSort(int a[], int first, int last, int temp[]) { if(first<last) { int mid = (first+last)/2; MergeSort(a, first, mid, temp); MergeSort(a, mid+1, last, temp); MergeArr(a, first, mid, last, temp); } }
8. 桶排序(Bucket Sort)/基数排序(Radix Sort)
说基数排序之前,我们先说桶排序:
基本思想:是将数列分到有限数量的桶里。每个桶再个别排序(有可能再使用别的排序算法或是以递回方式继续使用桶排序进行排序)。桶排序是鸽巢排序的一种归纳结果。当要被排序的阵列内的数值是均匀分配的时候,桶排序使用线性时间O(n)。但桶排序并不是比较排序,不受到O(n*log n)下限的影响。
简单来说,就是把数据分组,放在一个个的桶中,然后对每个桶里面的在进行排序。
例如要对大小为[1..1000]范围内的n个整数A[1..n]排序
首先,可以把桶设为大小为10的范围,具体而言,设集合B[1]存储[1..10]的整数,集合B[2]存储(10..20]的整数,…… ,集合B[i]存储((i-1)*10, i*10]的整数,i=1,2,..100,总共有100个桶。
然后,对A[1, ... , n]从头到尾扫描一遍,把每个A[i]放入对应的桶B[j]中。 再对这100个桶中每个桶里的数字排序,这时可用冒泡,选择,乃至快排,一般来说任何排序法都可以。
最后,依次输出每个桶里面的数字,且每个桶中的数字从小到大输出,这样就得到所有数字排好序的一个序列了。
假设有n个数字,有m个桶,如果数字是平均分布的,则每个桶里面平均有n/m个数字。如果对每个桶中的数字采用快速排序,那么整个算法的复杂度是 O(n+m*n/m*log(n/m))=O(n+n*logn-n*logm)。
从上式看出,当m接近n的时候,桶排序复杂度接近O(n)
当然,以上复杂度的计算是基于输入的n个数字是平均分布这个假设的。这个假设是很强的 ,实际应用中效果并没有这么好。如果所有的数字都落在同一个桶中,那就退化成一般的排序了。
前面说的几大排序算法 ,大部分时间复杂度都是O(n2),也有部分排序算法时间复杂度是O(nlogn)。而桶式排序却能实现O(n)的时间复杂度。但桶排序的缺点是:
1)首先是空间复杂度比较高,需要的额外开销大。排序有两个数组的空间开销,一个存放待排序数组,一个就是所谓的桶,比如待排序值是从0到m-1,那就需要m个桶,这个桶数组就要至少m个空间。
2)其次待排序的元素都要在一定的范围内等等。
桶式排序是一种分配排序。分配排序的特定是不需要进行关键码的比较,但前提是要知道待排序列的一些具体情况。
分配排序的基本思想:说白了就是进行多次的桶式排序。
基数排序过程无须比较关键字,而是通过“分配”和“收集”过程来实现排序。它们的时间复杂度可达到线性阶:O(n)。
实例:
扑克牌中52 张牌,可按花色和面值分成两个字段,其大小关系为:
花色: 梅花< 方块< 红心< 黑心
面值: 2 < 3 < 4 < 5 < 6 < 7 < 8 < 9 < 10 < J < Q < K < A
若对扑克牌按花色、面值进行升序排序,得到如下序列:
即两张牌,若花色不同,不论面值怎样,花色低的那张牌小于花色高的,只有在同花色情况下,大小关系才由面值的大小确定。这就是多关键码排序。
为得到排序结果,我们讨论两种排序方法。
方法1:先对花色排序,将其分为4 个组,即梅花组、方块组、红心组、黑心组。再对每个组分别按面值进行排序,最后,将4 个组连接起来即可。
方法2:先按13 个面值给出13 个编号组(2 号,3 号,...,A 号),将牌按面值依次放入对应的编号组,分成13 堆。再按花色给出4 个编号组(梅花、方块、红心、黑心),将2号组中牌取出分别放入对应花色组,再将3 号组中牌取出分别放入对应花色组,……,这样,4 个花色组中均按面值有序,然后,将4 个花色组依次连接起来即可。
设n 个元素的待排序列包含d 个关键码{k1,k2,…,kd},则称序列对关键码{k1,k2,…,kd}有序是指:对于序列中任两个记录r[i]和r[j](1≤i≤j≤n)都满足下列有序关系:
其中k1 称为最主位关键码,kd 称为最次位关键码。
两种多关键码排序方法:
多关键码排序按照从最主位关键码到最次位关键码或从最次位到最主位关键码的顺序逐次排序,分两种方法:
最高位优先(Most Significant Digit first)法,简称MSD 法:
1)先按k1排序分组,将序列分成若干子序列,同一组序列的记录中,关键码k1相等。
2)再对各组按k2排序分成子组,之后,对后面的关键码继续这样的排序分组,直到按最次位关键码kd对各子组排序后。
3)再将各组连接起来,便得到一个有序序列。扑克牌按花色、面值排序中介绍的方法一即是MSD法。
最低位优先(Least Significant Digit first)法,简称LSD法:
1) 先从kd 开始排序,再对kd-1进行排序,依次重复,直到按k1排序分组分成最小的子序列后。
2) 最后将各个子序列连接起来,便可得到一个有序的序列, 扑克牌按花色、面值排序中介绍的方法二即是LSD法。
基于LSD方法的链式基数排序的基本思想:
“多关键字排序”的思想实现“单关键字排序”。对数字型或字符型的单关键字,可以看作由多个数位或多个字符构成的多关键字,此时可以采用“分配-收集”的方法进行排序,这一过程称作基数排序法,其中每个数字或字符可能的取值个数称为基数。比如,扑克牌的花色基数为4,面值基数为13。在整理扑克牌时,既可以先按花色整理,也可以先按面值整理。按花色整理时,先按红、黑、方、花的顺序分成4摞(分配),再按此顺序再叠放在一起(收集),然后按面值的顺序分成13摞(分配),再按此顺序叠放在一起(收集),如此进行二次分配和收集即可将扑克牌排列有序。
基数排序:是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序。最后的次序就是高优先级高的在前,高优先级相同的低优先级高的在前。基数排序基于分别排序,分别收集,所以是稳定的。
9. 各种排序算法性能比较
1)各种排序的稳定性,时间复杂度和空间复杂度总结:
改错:上述快速排序算法的空间复杂度应改为O(log2n)。
为什么快速排序算法的空间复杂度为O(log2n)~O(n)?
快速排序算法的实现需要栈的辅助,栈的递归深度为O(log2n);当整个数列均有序时,栈的深度会达到O(n)。
我们比较时间复杂度函数的情况:
2)时间复杂度来说:
(1)平方阶(O(n2))排序
各类简单排序:直接插入、直接选择和冒泡排序;
(2)线性对数阶(O(n*logn))排序
快速排序、堆排序和归并排序;
(3)O(n1+§))排序,§是介于0和1之间的常数
希尔排序
(4)线性阶(O(n))排序
基数排序,此外还有桶、箱排序。
说明:
(1)当原表有序或基本有序时,直接插入排序和冒泡排序将大大减少比较次数和移动记录的次数,时间复杂度可降至O(n);
(2)而快速排序则相反,当原表基本有序时,将蜕化为冒泡排序,时间复杂度提高为O(n^2);
(3)原表是否有序,对简单选择排序、堆排序、归并排序和基数排序的时间复杂度影响不大。
3)稳定性:排序算法的稳定性:若待排序的序列中,存在多个具有相同关键字的记录,经过排序, 这些记录的相对次序保持不变,则称该算法是稳定的;若经排序后,记录的相对次序发生了改变,则称该算法是不稳定的。
稳定性的好处:排序算法如果是稳定的,那么从一个键上排序,然后再从另一个键上排序,第一个键排序的结果可以为第二个键排序所用。基数排序就是这样,先按低位排序,逐次按高位排序,低位相同的元素其顺序再高位也相同时是不会改变的。另外,如果排序算法稳定,可以避免多余的比较。
稳定的排序算法:冒泡排序、插入排序、归并排序和基数排序。
不是稳定的排序算法:选择排序、快速排序、希尔排序、堆排序。
4)选择排序算法准则:
每种排序算法都各有优缺点。因此,在实用时需根据不同情况适当选用,甚至可以将多种方法结合起来使用。
选择排序算法的依据:
影响排序的因素有很多,平均时间复杂度低的算法并不一定就是最优的。相反,有时平均时间复杂度高的算法可能更适合某些特殊情况。同时,选择算法时还得考虑它的可读性,以利于软件的维护。一般而言,需要考虑的因素有以下四点:
(1)待排序的记录数目n的大小;
(2)记录本身数据量的大小,也就是记录中除关键字外的其他信息量的大小;
(3)关键字的结构及其分布情况;
(4)对排序稳定性的要求。
设待排序元素的个数为n.
(1)当n较大,则应采用时间复杂度为O(n*logn)的排序方法:快速排序、堆排序或归并排序。
快速排序:是目前基于比较的内部排序中被认为是最好的方法,当待排序的关键字是随机分布时,快速排序的平均时间最短;
堆排序:如果内存空间允许且要求稳定性的;
归并排序:它有一定数量的数据移动,所以我们可能过与插入排序组合,先获得一定长度的序列,然后再合并,在效率上将有所提高。
(2)当n较大,内存空间允许,且要求稳定性:归并排序
(3)当n较小,可采用直接插入或直接选择排序。
直接插入排序:当元素分布有序,直接插入排序将大大减少比较次数和移动记录的次数。
直接选择排序:当元素分布有序,如果不要求稳定性,选择直接选择排序。
(4)一般不使用或不直接使用传统的冒泡排序。
(5)基数排序
它是一种稳定的排序算法,但有一定的局限性:
1、关键字可分解;
2、记录的关键字位数较少,如果密集更好;
3、如果是数字时,最好是无符号的,否则将增加相应的映射复杂度,可先将其正负分开排序。