排序
总体来说,排序分为内部排序和外部排序。其中,内部排序又分为插入排序、交换排序、选择排序、归并排序、基数排序、计数排序几大类。
内部排序
一、插入排序
基本思想是每次将一个待排序元素按其关键字大小插入到一个已排序好的子序列中,直到所有元素都排序完成。
- 直接插入排序:主体思想是逐步构造有序数列,例如在线性表L[1…n]中,以L[i]为分界,L[1…i]为有序表,L[i…n]为待排序序列,逐步遍历,将待排序序列中的元素插入有序序列。*
代码思想如下:
1.查找出L[i]在有序列表中的待插入位置k;
2.将L[k…i-1]向后移动;
3.将L[i]复制到L[k]处。
void InsertSort(int a[], int n){
int i, j;
for(i = 1; i<n;i++){
if(a[i]<a[i-1]){ //比前面的小
int t;t = a[i];
for(j = i-1;j>=0&&t<a[j];j--){
a[j+1] = a[j];
}
a[j+1] = t;
}
}
for(i = 0; i<n; i++)
cout<<a[i]<<",";
cout<<endl;
}
- 折半插入排序
排序的时间复杂度主要由两大部分组成:元素比较次数和元素交换次数,那么在直接插入排序的基础上,如果能将这二者优化一下,就可降低时间复杂度。于是,折半插入排序将元素比较次数进行优化。
代码整体思想与直接插入相似,唯一不同的是,因为在每趟排序中,L[i]前面的L[1…i-1]已经是有序表,那么在查询L[i]的插入位置时,就可以用折半查找的思想。注:使用折半查找,必须是有序的顺序表。
void InsertSort(int a[], int n){
int temp;
for(int i = 1; i < n; i++){
int low = 0;
int high = i-1;
temp = a[i];
while(low<high){
int mid = (low+high)/2;
if(a[mid] > temp) //插入位置在左表[low, mid)
high = mid-1;
else //插入位置在右表[mid,high]
low = mid;
}
for(int j = i-1; j> high;j--)
a[j+1]=a[j;
a[high+1] = temp;
}
}
- 希尔排序
插入排序适用于基本有序的排序表和数据量不大的数据表,在这个基础上,希尔提出了希尔排序。其思想为:将L[1…n]划分为若干形如L[i,i+d,i+2d,…i+kd]的特殊子表,即把相同增量的记录组成一个子表,对各个子表进行直接插入排序,最后构建出一个基本有序的表,然后总体来一趟直接插入排序。
过程思想:初始d=n/2向上取整,然后依次二分,最后一趟d=1,即成为直接插入排序。注:d的选取不一定每次严格除2取整,可以更改。
void ShellSort(int a[], int n){
int dk, i, j;
for(dk = n/2;d>=1; dk = dk/2){
for(i = dk+1; i<= n; ++i){
if(a[i]<a[i-dk]){
a[0] = a[i];
for(j = i-dk; j>0&&a[0]<a[j];j-=dk)
a[j+dk] = a[j];
a[j+dk] = a[0];
}
}
}
}
二、交换排序
顾名思义,交换排序即每次比较元素,比较后根据元素大小交换其相对位置。
- 冒泡排序:每趟确定一个最大/小值,经过n-1趟,每趟比较n-i次,i为已确定的有序表表长。
比较简单,直接上代码。
void BubbleSort(int a[], int n){
int i,j;
for(int i = 0; i<n-1; i++){
for(int j = n-1; j>i;j++){
if(a[j-1] > a[j]){
swap(a[j-1], a[j]);
}
}
}
}
- 快速排序:快排的思想是基于分治法的,每次在子表中选出一个基准元素,使得基准元素左子表都比他小,右子表都比他大(或者反过来)。
过程思想:类似于构建二叉树,每趟排序能分为两种情况:
1.该趟排序的基准元素在表首或者表尾(即左右子表必有一个为空),那么该趟至少能确定基准元素的位置,其他元素不一定能确定位置;
3.该趟排序的基准元素不在表首或者表尾(即左右子表均不为空),那么该趟可至少确定2的i-1个元素的位置,i为趟数。
代码如下:
int Partition(int a[], int low, int high){
int pivot = a[low];//设置该趟的基准元素,一般是表头元素
while(low<high){
while(low<high&&a[high]>=pivot)--high;
a[low] = a[high];
while(low<high&&a[low]<=pivot) ++low;
a[high] = a[low];
}
a[low] = pivot;
return low;
}
void QuickSort(int a[], int low, int high){
if(low<high){
int pivotpos = Partition(a, low, high);//划分
QuickSort(a, low, pivotpos-1);
QuickSort(a, pivotpos+1, high);
}
}
三、选择排序
选择排序的基本思想是,每一趟在后面的n-i+1个无序元素中选取最小/大的元素进行排序。共排序n-1趟,每趟移动元素较少。
- 简单选择排序
void SelectSort(int a[], int n){
for(int i = 0; i < n-1; i++){
int min = i; //记录最小元素的位置
for(int j = i; j<n;j++)
if(a[j]<a[i]) min = j;
if(min != i) swap(a[min], a[i]);
}
}
- 堆排序:类似于二叉排序树,但是没有严格的左根右序列
1.大根堆:根大于左右
2.小根堆:根小于左右
可以将堆视为一颗完全二叉树,故其适用于二叉树的顺序存储。
以大根堆为例,代码如下:
void BuildMaxHeap(int a[], int len){
for(int i = len/2;i>0;i--){
HeadAdjust(a, i, len);
}
}
void HeadAdjust(int a[], int k, int len){
a[0] = a[k];
for(int i =2*k; i<= len; i*=2){
if(i<len&&a[i]<a[i+1])
i++;
if(a[0] >= a[i]) break;
else{
a[k] = a[i];
k = i;
}
}
a[k] = a[0];
}
void HeapSort(int a[], int n){
BuildMaxHeap(a, n);
for(int i = n; i>1; i--){
swap(a[i], a[1]);
HeadMaxHeap(a, 1, i-1);
}
}
四、归并排序 基数排序 计数排序
归并排序也是采用了分治的思想。外排一般也是采用多路归并。
- 二路归并排序
归并是指将两个及两个以上的有序表合并成一个有序表的过程。
过程思想:Merge是将前后相邻的两个有序表归并为一个有序表。
int *b = (int *)malloc((n+1)*sizeof(int));
void Merge(int a[], int low, int mid, int high){
int i, j , k;
for(int k = low;k<= high;k++)
b[k] = a[k];
for(i = low, j = mid+1, k = i;i<=mid&&j<=high;k++){
if(b[i]<=b[j])
a[k] = b[i++];
else
a[k] = b [j++];
}
while(i<=mid) a[k++] = b[i++];
while(j<=high) a[k++] = b[j++];
}
void MergeSort(int a[], int low, int high){
if(low<high){
int mid = (low+high)/2;
MergeSort(a, low, mid);
MergeSort(a, mid+1, high);
Merge(a, low, mid, high);
}
}
- 基数排序(待更新…)
每次排序都基于数字的某个数位进行排序,一般分为两种:最高位优先MSD,最低位优先LSD - 计数排序(待更新…)
五、内部排序的比较
算法种类 | 时间复杂度 | 空间复杂度 | 适用范围 | 稳定性 | ||
---|---|---|---|---|---|---|
最好情况 | 最坏 | 平均 | ||||
直接插入排序 | O(n) | O(n2) | O(n2) | O(1) | n较小,顺序表、链表(无需移动元素) | 稳定 |
折半插入排序 | O(nlogn) | O(n2) | O(1) | n较小,顺序表 | 稳定 | |
希尔排序 | O(1) | n较小,基本有序,顺序表 | 不稳定 | |||
冒泡排序 | O(n) | O(n2) | O(n2) | O(1) | 顺序表、链表 | 稳定 |
快速排序 | O(nlogn) | O(n2) | O(nlogn) | O(logn) | 顺序表 | 不稳定 |
简单选择排序 | O(n2) | O(n2) | O(n2) | O(1) | 顺序表、链表 | 不稳定 |
堆排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(1) | 顺序表、n比较大 | 不稳定 |
二路归并 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 顺序表 | 稳定 |
基数排序 | O(d(n+r)) | O(d(n+r)) | O(d(n+r)) | O® | 顺序、链表 | 稳定 |
外部排序
外部排序即大文件的排序,一般来说,大文件都存在外存里。外排常采用归并排序的方法:首先根据内存缓冲区的大小,将外存上的文件划分为若干长度为L的子文件(内存块与外存块),依次读入内存并根据内部排序算法对它们进行排序,排序完成后,将这些有序的子文件写回外存,这些子文件称为归并段或者顺串,然后对这些归并段进行逐趟归并,最终得到整个有序文件。
外部排序所用时间 = 内部排序时间 + 外部读写时间 + 内部归并时间
k路归并
在外排的时间构成中,不难看出,其最消耗时间的是外存信息的读写时间,如果能一次性读写大量文件,及提高归并排序的路数,则可显著减少外排所用时间,于是,提出了k路归并。
- 一般地,对r个初始归并段R1…Rr,做k路归并。第一趟可将r个初始归并段归并为r/k向上取整个归并段,以后每趟归并可将m个归并段归并成m/k向上取整个归并段,直至最后形成一个大的归并段。可知归并树的高度-1=logkr向上取整=归并趟数s,易得增大归并路数k或者减少初始归并个数r,都能减少归并趟数s,从而减少I/O时间,提高外排速度。
多路平衡归并、败者树
增大归并路数k固然能减少磁盘读写时间,但与此同时,内部归并排序的时间也增加了。对于k路归并排序,假定块大小为n,在每个归并段中选出最大/小的关键字需要比对n-1次,而选出的这些关键字后还需要比对k-1次,最后进行s趟,其比较次数为:s(k-1)(n-1)=logkr(k-1)(n-1)=log2r(n-1)(k-1)/log2k,易知对比次数随着k的增大而增大,这将抵消k增大减少外存读写时间带来的收益,于是,为了使得内部排序不受k的影响,引入了败者树。
- 败者树
类似于天下第一武道会的排布,但是与其不同的是,除了根结点以外的分支结点,存放的是比较失败的归并段的段号,而根结点存放的是最后比对的胜出者段号,即上一趟比对的最大/小值所在段号,叶子结点(其实并不存在),存放的是该段此趟比对的关键字。对于k路归并,构造败者树需要进行k-1次比较,决出胜者后,该关键字所在段出一个关键字补全空下来的叶子结点,,继续比较。即在下图中,假设悟空是最终胜者,在悟空一开始所在位置,替补悟空队的队员。
因为k路归并的败者树树高为log2k向上取整+1(因为亚军上头还有个冠军,所以在完全二叉树的树高上加一),所以这回找关键字只需要log2k向上取整次比较了。
那么,把之前的公式可以优化成s(n-1)[log2k]=logkr[log2k]=(n-1)[log2r],这回内部归并比较次数就与k无关了。注:k并不是越大越好。 - 置换选择排序(生成初始归并段)
由刚开始的公式可知,减少初始归并段数r也可以减少归并趟数。假设总的记录个数为n,块大小为l,那么初始归并段数r=[n/l]。
思想:
假设初始待排文件为FI,初始归并段输出文件为FO,内存工作区为WA,FO与WA初始为空,WA可最多容纳w个记录。
1.从FI中输入w个记录到WA中;
2.从WA中选取关键字最小值的记录,记为MINIMAX记录;(这步可以用败者树)
3.将MINIMAX输出到FO中;
4.若FI不空,则从FI输入下一个记录到WA中;
5.从WA中所有比MINIMAX大的记录中选取最小关键字,记为新的MINIMAX记录;
6.重复3-5,知道选不出新的MINIMAX为止,由此得到一个初始归并段,输出一个归并段的结束标志到FO中;
7.重复2-6,直至WA空,由此得到全部初始归并段。 - 最佳归并树
在上面的置换选择排序中,得到的归并段数长短不一,其可以成为一颗多叉树,每个结点的权值代表该归并段的长度,树的带权路径长度WPL为归并过程中的总读记录数,所以I/O时间=2WPL。为了使得WPL最小,可以用哈夫曼树的思想。
在构造哈夫曼树时,存在段数不足,构造不出最佳方案的现象,于是引入了虚段,即空记录,用来充数的。
确定虚段的个数:
设度为0的结点有n0个,度为k的结点有nk个,总结点树有n个,易得: - n=nk+n0 总结点数
- n-1 = nk 总边数
由上述公式可知严格k叉数有:n0= (k-1)nk+1,即nk=(n0-1)/(k-1) - 若(n0-1)%(k-1)=0,说明n0个结点(初始归并段)正好可以构造k叉归并树,此时内结点有nk个;
- 若(n0-1)%(k-1)=u,即多余了u个结点,那么,这u个结点中,取出来一个升级为内结点nk,另外的u-1个结点成为该结点的叶子结点,应该补上k-(u+1)个结点(虚段),u+1是因为该结点代替了一个叶子结点。