各类排序算法总结
一. 排序的基本概念
排序(Sorting)是计算机程序设计中的一种重要操作,其功能是对一个数据元素集合或序列重新排列成一个按数据元素某个项值有序的序列。
有 n 个记录的序列{R1,R2,…,Rn},其相应关键字的序列是{K1,K2,…,Kn},相应的下标序列为1,2,…,n。通过排序,要求找出当前下标序列1,2,…, n 的一种排列p1,p2, …,pn,使得相应关键字满足如下的非递减(或非递增)关系,即:Kp1≤Kp2≤…≤Kpn,这样就得到一个按关键字有序的记录序列{Rp1,Rp2,…,Rpn}。
作为排序依据的数据项称为“排序码”,也即数据元素的关键码。若关键码是主关键码,则对于任意待排序序列,经排序后得到的结果是唯一的;若关键码是次关键码,排序结果可能不唯一。
实现排序的基本操作有两个:
(1)“比较”序列中两个关键字的大小;
(2)“移动”记录。
若对任意的数据元素序列,使用某个排序方法,对它按关键码进行排序:若相同关键码元素间的位置关系,排序前与排序后保持一致,称此排序方法是稳定的;而不能保持一致的排序方法则称为不稳定的。
二.插入类排序
1.直接插入排序
直接插入排序是最简单的插入类排序。仅有一个记录的表总是有序的,因此,对 n 个记录的表,可从第二个记录开始直到第 n 个记录,逐个向有序表中进行插入操作,从而得到n个记录按关键码有序的表。
它是利用顺序查找实现“在R[1..i-1]中查找R[i]的插入位置”的插入排序。
注意直接插入排序算法的三个要点:
(1)从R[i-1]起向前进行顺序查找,监视哨设置在R[0];
[cpp] view plaincopy
- R[0] = R[i]; // 设置“哨兵”
- for (j=i-1; R[0].key<R[j].key; --j) // 从后往前找
- return j+1; // 返回R[i]的插入位置为j+1
(2)对于在查找过程中找到的那些关键字不小于R[i].key 的记录,可以在查找的同时实现向后移动,即:查找与移动同时进行.
[cpp] view plaincopy
- for (j=i-1; R[0].key<R[j].key; --j)
- {
- R[j+1] = R[j];
- }
(3)i = 2,3,…, n, 实现整个序列的排序(从i = 2开始).
【算法如下】
[cpp] view plaincopy
- //C++代码,确保能够运行
- void insertionSort(int *R,int length)
- {
- for (int i = 2; i <= length; ++i)
- {
- R[0] = R[i]; //设为监视哨
- int j;
- for (j = i-1; R[0] < R[j]; --j)
- {
- R[j+1] = R[j]; //边查找边后移
- }
- R[j+1] = R[0]; // 插入到正确位置
- }
- }
【性能分析】
(1)空间效率:仅用了一个辅助单元,空间复杂度为O(1)。只需R[0]做辅助.
(2)时间效率:向有序表中逐个插入记录的操作,进行了n-1 趟,每趟操作分为比较关键码和移动记录,而比较的次数和移动记录的次数取决于待排序列按关键码的初始排列。
直接插入排序的最好情况的时间复杂度为O(n),平均时间复杂度为O(n^2)。
(3)稳定性:直接插入排序是一个稳定的排序方法。
总体来说:直接插入排序比较适用于带排序数目少,且基本有序的情况下.
2.折半插入排序
直接插入排序的基本操作是向有序表中插入一个记录,插入位置的确定通过对有序表中记录按关键码逐个比较得到的。平均情况下总比较次数约为(n^2)/4。既然是在有序表中确定插入位置,可以不断二分有序表来确定插入位置,即一次比较,通过待插入记录与有序表居中的记录按关键码比较,将有序表一分为二,下次比较在其中一个有序子表中进行,将子表又一分为二。这样继续下去,直到要比较的子表中只有一个记录时,比较一次便确定了插入位置。
折半插入排序是利用折半查找实现“在R[1..i-1]中查找R[i]的插入位置”。
综上:折半插入排序只是减少了比较的次数,因此折半插入排序总的时间复杂度仍是O(n^2).
3.希尔排序
希尔排序又称缩小增量排序,较直接插入排序和折半插入排序方法有较大的改进。直接插入排序算法简单,在 n 值较小时,效率比较高,在 n 值很大时,若序列按关键码基本有序,效率依然较高,其时间效率可提高到O(n)。希尔排序即是从这两点出发,给出插入排序的改进方法。
希尔排序的基本思想是:先将待排序记录序列分割成若干个“较稀疏的”子序列,分别进行直接插入排序。经过上述粗略调整, 整个序列中的记录已经基本有序,最后再对全部记录进行一次直接插入排序。具体实现时,首先选定两个记录间的距离d1,在整个待排序记录序列中将所有间隔为d1 的记录分成一组,进行组内直接插入排序,然后再取两个记录间的距离d2<d1,在整个待排序记录序列中,将所有间隔为d2 的记录分成一组,进行组内直接插入排序,直至选定两个记录间的距离dt=1 为止,此时只有一个子序列,即整个待排序记录序列。
【性能分析】
(1)空间效率:仅用了一个辅助单元,空间复杂度为O(1)。
(2)时间效率:希尔排序时效分析很难,关键码的比较次数与记录移动次数依赖于步长因子序列的选取,特定情况下可以准确估算出关键码的比较次数和记录的移动次数。目前还没有人给出选取最好的步长因子序列的方法。步长因子序列可以有各种取法,有取奇数的,也有取质数的,但需要注意:步长因子中除 1 外没有公因子,且最后一个步长因子必须为1。
O(log2n)~O(n^2)之间的一个值.
(3)稳定性:希尔排序方法是一个不稳定的排序方法。
三.交换类排序
交换排序主要是通过两两比较待排记录的关键码,若发生与排序要求相逆,则交换之。
1.冒泡排序(相邻比较法)
冒泡排序是最简单的一种交换排序。
假设在排序过程中,记录序列R[1..n]的状态为:
则第 i 趟起泡插入排序的基本思想为:借助对无序序列中的记录进行“交换”的操作,将无序序列中关键字最大的记录“交换”到R[n-i+1]的位置上。
【算法如下】
[cpp] view plaincopy
- //C++代码
- void bubbleSort(int *R,int length)
- {
- bool change = true;
- for (int i = 0; i != length-1 && change; ++i)
- {
- change = false;
- for (int j = 0; j != length-i-1; ++j)
- {
- if (R[j] > R[j+1]) //如果相邻元素中大者在前,交换之
- {
- int temp = R[j];
- R[j] = R[j+1];
- R[j+1] = temp;
- change = true;
- }
- }
- }
- }
【性能分析】
(1)空间效率:仅用了一个辅助单元,空间复杂度为O(1)。
(2)时间效率:最好情况的时间复杂度为O(n),平均时间复杂度为O(n^2)。
(3)稳定性:冒泡排序法是一种稳定的排序方法
总比较次数
2.快速排序
快速排序是通过比较关键码、交换记录,以某个记录为界(该记录称为支点),将待排序列分成两部分。其中,一部分所有记录的关键码大于等于支点记录的关键码,另一部分所有记录的关键码小于支点记录的关键码。我们将待排序列按关键码以支点记录分成两部分的过程,称为一次划分。对各部分不断划分,直到整个序列按关键码有序.
如果每次划分对一个元素定位后,该元素的左侧子序列与右侧子序列的长度相同,则下一步将是对两个长度减半的子序列进行排序,这是最理想的情况!
【算法如下】
[cpp] view plaincopy
- //伪码表示
- //一趟快速排序算法:
- int Partition1 (Elem R[], int low, int high)
- {
- pivotkey = R[low].key; // 用子表的第一个记录作枢轴记录
- while (low<high) // 从表的两端交替地向中间扫描
- {
- while (low<high && R[high].key>=pivotkey)
- {
- --high;
- }
- R[low]←→R[high]; // 将比枢轴记录小的记录交换到低端
- while (low<high && R[low].key<=pivotkey)
- {
- ++low;
- }
- R[low]←→R[high]; // 将比枢轴记录大的记录交换到高端
- }
- return low; // 返回枢轴所在位置
- }
容易看出,调整过程中的枢轴位置并不重要,因此,为了减少记录的移动次数,应先将枢轴记录“移出”,待求得枢轴记录应在的位置之后(此时low=high),再将枢轴记录到位。
将上述“一次划分”的算法改写如下:
[cpp] view plaincopy
- int Partition2 (Elem R[], int low, int high)
- {
- R[0] = R[low]; // 用子表的第一个记录作枢轴记录
- pivotkey = R[low].key; // 枢轴记录关键字
- while (low < high) // 从表的两端交替地向中间扫描
- {
- while (low<high && R[high].key>=pivotkey)
- {
- --high;
- }
- R[low] = R[high]; // 将比枢轴记录小的记录移到低端
- while (low<high && R[low].key<=pivotkey)
- {
- ++low;
- }
- R[high] = R[low]; // 将比枢轴记录大的记录移到高端
- }
- R[low] = R[0]; // 枢轴记录到位
- return low; // 返回枢轴位置
- }
[cpp] view plaincopy
- //递归形式的快速排序算法:
- void QSort (Elem R[], int low, int high)
- {
- // 对记录序列R[low..high]进行快速排序
- if (low < high-1) // 长度大于1
- {
- pivotloc = Partition(L, low, high); // 将L.r[low..high]一分为二
- QSort(L, low, pivotloc-1); // 对低子表递归排序,pivotloc 是枢轴位置
- QSort(L, pivotloc+1, high); // 对高子表递归排序
- }
- }
- void QuickSort(Elem R[], int n) // 对记录序列进行快速排序
- {
- QSort(R, 1, n);
- }
【性能分析】
(1)空间效率:快速排序是递归的,每层递归调用时的指针和参数均要用栈来存放,递归调用层次数与上述二叉树的深度一致。因而,存储开销在理想情况下为O(log2n),即树的高度;在最坏情况下,即二叉树是一个单链,为O(n)。
(2)时间效率:在n 个记录的待排序列中,一次划分需要约 n 次关键码比较,时效为O(n),若设T(n)为对 n 个记录的待排序列进行快速排序所需时间。理想情况下:每次划分,正好将分成两个等长的子序列,则
[plain] view plaincopy
- T(n)≤cn+2T(n/2) c 是一个常数
- ≤cn+2(cn/2+2T(n/4))=2cn+4T(n/4)
- ≤2cn+4(cn/4+T(n/8))=3cn+8T(n/8)
- ······
- ≤cnlog2n+nT(1)=O(nlog2n)
可以证明,QuickSort的平均计算也是O(nlog2n).
最坏情况下:即每次划分,只得到一个子序列,时效为O(n^2)。
快速排序是通常被认为在同数量级O(nlog2n)的排序方法中平均性能最好的。但若初始序列按关键码有序或基本有序时,快排序反而蜕化为冒泡排序。为改进之,通常以“三者取中法”来选取支点记录,即将排序区间的两个端点与中点三个记录关键码居中的调整为支点记录。
(3)快速排序是一个不稳定的排序方法.
(4) 最惨情况:空间复杂度->O(n),时间复杂度->O(n^2)
平均情况:空间复杂度->O(log2n),时间复杂度->O(nlog2n)
(5)快速排序比较适用于输入规模n较大的情况.
四.选择类排序
1.选择排序
简单选择排序是最简单的一种选择类的排序方法。假设排序过程中,待排记录序列的状态为:
并且有序序列中所有记录的关键字均小于无序序列中记录的关键字,则第i 趟简单选择排序是,从无序序列R[i..n]的n-i+1 记录中选出关键字最小的记录加入有序序列。
操作方法:第一趟,从n 个记录中找出关键码最小的记录与第1 个记录交换;第二趟,从第二个记录开始的n-1 个记录中再选出关键码最小的记录与第2 个记录交换;如此,第i趟,则从第i 个记录开始的n-i+1 个记录中选出关键码最小的记录与第 i 个记录交换,直到整个序列按关键码有序。
【算法如下】
[cpp] view plaincopy
- //C++代码
- int selectMinIndex(int *A,int index,int length)
- {
- int min = index;
- for (int i = index+1; i != length; ++i)
- {
- if (A[i] < A[min])
- {
- min = i;
- }
- }
- return min;
- }
- void selectSort(int *A,int length)
- {
- for (int i = 0; i != length; ++i)
- {
- int k = selectMinIndex(A,i,length);
- if (k != i)
- {
- int temp = A[i];
- A[i] = A[k];
- A[k] = temp;
- }
- }
- }
【性能分析】
(1)空间效率:仅用了一个辅助单元,空间复杂度为O(1)。
(2)时间效率:简单选择排序的最好和平均时间复杂度均为O(n^2)。
(3)稳定性:不同教材对简单选择排序的稳定性有争议,一般认为,若是从前往后比较来选择第i 小的记录则该算法是稳定的,若是从后往前比较来选择第i 小的记录则该算法是不稳定的。
2.堆排序
堆排序的特点是,在以后各趟的“选择”中利用在第一趟选择中已经得到的关键字比较的结果.
堆的定义: 堆是满足下列性质的数列{r1, r2, …,rn}:
若将此数列看成是一棵完全二叉树,则堆或是空树或是满足下列特性的完全二叉树:其左、右子树分别是堆,并且当左/右子树不空时,根结点的值小于(或大于)左/右子树根结点的值。
由此,若上述数列是堆,则r1 必是数列中的最小值或最大值,分别称作小顶堆或大顶堆。
堆排序即是利用堆的特性对记录序列进行排序的一种排序方法。具体作法是:设有 n 个元素,将其按关键码排序。首先将这 n 个元素按关键码建成堆,将堆顶元素输出,得到n 个元素中关键码最小(或最大)的元素。然后,再对剩下的n-1 个元素建成堆,输出堆顶元素,得到n 个元素中关键码次小(或次大)的元素。如此反复,便得到一个按关键码有序的序列。称这个过程为堆排序。
因此,实现堆排序需解决两个问题:
(1)如何将n 个元素的序列按关键码建成堆。
建堆方法:对初始序列建堆的过程,就是一个反复进行筛选的过程。n 个结点的完全二叉树,则最后一个结点是第 n/2 个结点的孩子。对第 n/2 个结点为根的子树筛选,使该子树成为堆,之后向前依次对各结点为根的子树进行筛选,使之成为堆,直到根结点。
(2)输出堆顶元素后,怎样调整剩余n-1 个元素,使其按关键码成为一个新堆。
调整方法:设有m 个元素的堆,输出堆顶元素后,剩下m-1 个元素。将堆底元素送入堆顶,堆被破坏,其原因仅是根结点不满足堆的性质。将根结点与左、右孩子中较小(或小大)的进行交换。若与左子女交换,则左子树堆被破坏,且仅左子树的根结点不满足堆的性质;若与右子女交换,则右子树堆被破坏,且仅右子树的根结点不满足堆的性质。继续对不满足堆性质的子树进行上述交换操作,直到叶子结点,堆被建成。称这个自根结点到叶子结点的调整过程为筛选。
【算法如下】
堆排序的算法如下所示:
[cpp] view plaincopy
- void heapSort ( Elem R[], int n ) // 对记录序列R[1..n]进行堆排序。
- {
- for ( i=n/2; i>0; --i ) // 把R[1..n]建成大顶堆
- HeapAdjust ( R, i, n );
- for ( i=n; i>1; --i )
- {
- R[1]←→R[i];
- //将堆顶记录和当前未经排序子序列,R[1..i]中最后一个记录相互交换
- HeapAdjust(R, 1, i-1); // 将R[1..i-1] 重新调整为大顶堆
- }
- }?
其中筛选的算法如下所示。为将R[s..m]调整为“大顶堆”,算法中“筛选”应沿关键字较大的孩子结点向下进行。
[cpp] view plaincopy
- void HeapAdjust (Elem R[], int s, int m)
- {
- /* 已知R[s..m]中记录的关键字除R[s].key 之外均满足堆的定义,本函数调整R[s] 的关
- 键字,使R[s..m]成为一个大顶堆(对其中记录的关键字而言)*/
- rc = R[s];
- for ( j=2*s; j<=m; j*=2 ) // 沿key 较大的孩子结点向下筛选
- {
- if ( j<m && R[j].key<R[j+1].key )
- ++j; // j 为key 较大的记录的下标
- if ( rc.key >= R[j].key )
- break; // rc 应插入在位置s 上
- R[s] = R[j];
- s = j;
- }
- R[s] = rc; // 插入
- }
【性能分析】
(1)空间效率:仅用了一个辅助单元,空间复杂度为O(1)。
(2)时间效率:
①对深度为k 的堆,“筛选”所需进行的关键字比较的次数至多为2(k-1);
②对n 个关键字,建成深度为h(=ëlog2nû+1)的堆,所需进行的关键字比较的次数至多为4n;
③调整“堆顶”n-1 次,总共进行的关键字比较的次数不超过
[plain] view plaincopy
- 2(?log2(n-1)?+ ?log2(n-2)?+ …+log22)<2n(?log2n?)
因此,堆排序的平均和最坏时间复杂度均为O(nlogn)。
(3)堆排序是一个不稳定的排序方法。
3.二路归并排序:
【算法思想】
归并排序的基本思想是:将两个或两个以上的有序子序列“归并”为一个有序序列。
在内部排序中,通常采用的是2-路归并排序。即:将两个位置相邻的有序子序列,
空间复杂度为O(n),稳定,时间复杂度O(nlog2n)
【算法如下】
[cpp] view plaincopy
- //伪代码,不一定能够运行
- void Merge(Elem SR[], Elem TR[], int i, int m, int n)
- {
- // 将有序的SR[i..m]和SR[m+1..n]归并为有序的TR[i..n]
- for (j=m+1, k=i; i<=m && j<=n; ++k) // 将SR 中记录由小到大地并入TR
- {
- if (SR[i].key<=SR[j].key)
- TR[k] = SR[i++];
- else
- TR[k] = SR[j++];
- }
- if (i<=m)
- TR[k..n] = SR[i..m]; // 将剩余的SR[i..m]复制到TR
- if (j<=n)
- TR[k..n] = SR[j..n]; // 将剩余的SR[j..n]复制到TR
- }
归并排序的算法可以有两种形式:递归的和递推的,它是由两种不同的程序设计思想得出的。
在此,只讨论递归形式的算法,这是一种自顶向下的分析方法:如果记录无序序列R[s..t]的两部分R[s..ë(s+t)/2û]和R[ë(s+t)/2+1..tû]分别按关键字有序,则利用上述归并算法很容易将它们归并成整个记录序列是一个有序序列,由此,应该先分别对这两部分进行2-路归并排序。
[cpp] view plaincopy
- void Msort ( Elem SR[], Elem TR1[], int s, int t )
- {
- if (s==t)
- TR1[s] = SR[s];
- else
- {
- m = (s+t)/2; // 将SR[s..t]平分为SR[s..m]和SR[m+1..t]
- Msort (SR, TR2, s, m);
- // 递归地将SR[s..m]归并为有序的TR2[s..m]
- Msort (SR, TR2, m+1, t);
- //递归地SR[m+1..t]归并为有序的TR2[m+1..t]
- Merge (TR2, TR1, s, m, t);
- // 将TR2[s..m]和TR2[m+1..t]归并到TR1[s..t]
- }
- }
- void MergeSort (Elem R[]) // 对记录序列R[1..n]作2-路归并排序。
- {
- MSort(R, R, 1, n);
- }
【性能分析】
(1)空间效率:需要一个与表等长的辅助元素数组空间,所以空间复杂度为O(n)。
(2)时间效率:对n 个元素的表,将这n 个元素看作叶结点,若将两两归并生成的子表看作它们的父结点,则归并过程对应由叶向根生成一棵二叉树的过程。所以归并趟数约等于二叉树的高度-1,即log2n,每趟归并需移动记录n 次,故时间复杂度为O(nlog2n)。
(3)稳定性:归并排序是一个稳定的排序方法。
稳定的排序算法:
插入排序、冒泡排序、归并排序、基排序(插入冒泡归并基)
非稳定的排序算法:
快速排序、选择排序、希尔排序、堆排序(快速选择希尔堆)